hash = md5($model->email); }); static::updating(function ($model) { if ($model->user_id && Helper::isUserSyncEnabled()) { $user = get_user_by('email', $model->email); $email_mismatch = false; if (!$user) { $email_mismatch = true; $user = get_user_by('ID', $model->user_id); } if ($user) { if ($model->first_name && $model->first_name != $user->first_name) { update_user_meta($user->ID, 'first_name', $model->first_name); } if ($model->last_name && $model->last_name != $user->last_name) { update_user_meta($user->ID, 'last_name', $model->last_name); } /** * Determine whether to update the WordPress user email when there is a mismatch. * * This filter allows you to control whether the WordPress user email should be updated * when there is a mismatch between the subscriber email and the WordPress user email. * * @param bool Whether to update the WordPress user email. Default false. * @since 2.3.1 * */ if ($email_mismatch && apply_filters('fluentcrm_update_wp_user_email_on_change', false)) { $user->user_email = $model->email; wp_update_user($user); } $model->user_id = $user->ID; // in case user id mismatch } } }); } /** * $searchable Columns in table to search * @var array */ protected $searchable = [ 'email', 'first_name', 'last_name', 'address_line_1', 'address_line_2', 'postal_code', 'city', 'state', 'country', 'phone', 'status' ]; /** * Local scope to filter subscribers by search/query string * @param \FluentCrm\Framework\Database\Query\Builder $query * @param string $search * @param boolean $custom_fields * @return \FluentCrm\Framework\Database\Query\Builder $query */ public function scopeSearchBy($query, $search, $custom_fields = false) { if ($search) { $fields = $this->searchable; $query->where(function ($query) use ($fields, $search, $custom_fields) { $query->where(array_shift($fields), 'LIKE', "%$search%"); $nameArray = explode(' ', $search); if (count($nameArray) >= 2) { $query->orWhere(function ($q) use ($nameArray) { $fname = array_shift($nameArray); $lastName = implode(' ', $nameArray); $q->where('first_name', 'LIKE', "%$fname%") ->orWhere('last_name', 'LIKE', "%$lastName%"); }); } foreach ($fields as $field) { $query->orWhere($field, 'LIKE', "%$search%"); } }); /** * If contact list has custom field * Then this block is responsible for searching by custom filed */ if ($custom_fields) { $query->orWhere(function ($q) use ($search, $custom_fields) { $q->whereHas('custom_field_meta', function ($q) use ($search) { $q->where('value', 'LIKE', "%$search%"); }); }); } } return $query; } /** * Local scope to filter subscribers by search/query string * @param \FluentCrm\Framework\Database\Query\Builder $query * @param array $statuses * @return \FluentCrm\Framework\Database\Query\Builder $query */ public function scopeFilterByStatues($query, $statuses) { if ($statuses) { $query->whereIn('status', $statuses); } return $query; } /** * Local scope to filter subscribers by contact type * @param \FluentCrm\Framework\Database\Query\Builder $query * @param array $statuses * @return \FluentCrm\Framework\Database\Query\Builder $query */ public function scopeFilterByContactType($query, $type) { if ($type) { $query->where('contact_type', $type); } return $query; } /** * Local scope to filter subscribers by tags * @param \FluentCrm\Framework\Database\Query\Builder $query * @param array $keys * @param string $filterBy id/slug * @return \FluentCrm\Framework\Database\Query\Builder $query */ public function scopeFilterByTags($query, $keys, $filterBy = 'id') { $prefix = 'fc_'; $keys = Sanitize::sanitizeTagIds($keys, false); return $query->whereIn('id', function ($q) use ($prefix, $keys, $filterBy) { $q->from($prefix . 'tags') ->join( $prefix . 'subscriber_pivot', $prefix . 'subscriber_pivot.object_id', '=', $prefix . 'tags.id' ) ->where($prefix . 'subscriber_pivot.object_type', 'FluentCrm\App\Models\Tag') ->whereIn($prefix . 'tags.' . $filterBy, $keys) ->groupBy($prefix . 'subscriber_pivot.subscriber_id') ->select($prefix . 'subscriber_pivot.subscriber_id'); }); } /** * Local scope to filter subscribers by not in tags * @param \FluentCrm\Framework\Database\Query\Builder $query * @param array $keys * @param string $filterBy id/slug * @return \FluentCrm\Framework\Database\Query\Builder $query */ public function scopeFilterByNotInTags($query, $keys, $filterBy = 'id') { $prefix = 'fc_'; $keys = Sanitize::sanitizeTagIds($keys, false); return $query->whereNotIn('id', function ($q) use ($prefix, $keys, $filterBy) { $q->from($prefix . 'tags') ->join( $prefix . 'subscriber_pivot', $prefix . 'subscriber_pivot.object_id', '=', $prefix . 'tags.id' ) ->where($prefix . 'subscriber_pivot.object_type', 'FluentCrm\App\Models\Tag') ->whereIn($prefix . 'tags.' . $filterBy, $keys) ->groupBy($prefix . 'subscriber_pivot.subscriber_id') ->select($prefix . 'subscriber_pivot.subscriber_id'); }); } /** * Local scope to filter subscribers by lists * @param \FluentCrm\Framework\Database\Query\Builder $query * @param array $keys * @param string $filterBy id/slug * @return \FluentCrm\Framework\Database\Query\Builder $query */ public function scopeFilterByLists($query, $keys, $filterBy = 'id') { $prefix = 'fc_'; $keys = Sanitize::sanitizeListIds($keys, false); return $query->whereIn('id', function ($q) use ($prefix, $keys, $filterBy) { $q->from($prefix . 'lists') ->join( $prefix . 'subscriber_pivot', $prefix . 'subscriber_pivot.object_id', '=', $prefix . 'lists.id' ) ->where($prefix . 'subscriber_pivot.object_type', 'FluentCrm\App\Models\Lists') ->whereIn($prefix . 'lists.' . $filterBy, $keys) ->groupBy($prefix . 'subscriber_pivot.subscriber_id') ->select($prefix . 'subscriber_pivot.subscriber_id'); }); } /** * Local scope to filter subscribers by not in lists * @param \FluentCrm\Framework\Database\Query\Builder $query * @param array $keys * @param string $filterBy id/slug * @return \FluentCrm\Framework\Database\Query\Builder $query */ public function scopeFilterByNotInLists($query, $keys, $filterBy = 'id') { $prefix = 'fc_'; $keys = Sanitize::sanitizeListIds($keys, false); return $query->whereNotIn('id', function ($q) use ($prefix, $keys, $filterBy) { $q->from($prefix . 'lists') ->join( $prefix . 'subscriber_pivot', $prefix . 'subscriber_pivot.object_id', '=', $prefix . 'lists.id' ) ->where($prefix . 'subscriber_pivot.object_type', 'FluentCrm\App\Models\Lists') ->whereIn($prefix . 'lists.' . $filterBy, $keys) ->groupBy($prefix . 'subscriber_pivot.subscriber_id') ->select($prefix . 'subscriber_pivot.subscriber_id'); }); } /** * Many2Many: Subscriber belongs to many tags * @return \FluentCrm\Framework\Database\Orm\Relations\BelongsToMany */ public function tags() { $class = __NAMESPACE__ . '\Tag'; return $this->belongsToMany( $class, 'fc_subscriber_pivot', 'subscriber_id', 'object_id' ) ->wherePivot('object_type', $class) ->withPivot('object_type') ->orderBy('title', 'ASC') ->withTimestamps(); } public function company() { return $this->belongsTo(__NAMESPACE__ . '\Company', 'company_id', 'id'); } public function companies() { $class = __NAMESPACE__ . '\Company'; return $this->belongsToMany( $class, 'fc_subscriber_pivot', 'subscriber_id', 'object_id' ) ->wherePivot('object_type', $class) ->withPivot('object_type') ->withTimestamps(); } /** * Local scope to filter subscribers by companies * @param \FluentCrm\Framework\Database\Query\Builder $query * @param array $keys * @param string $filterBy id/slug * @return \FluentCrm\Framework\Database\Query\Builder $query */ public function scopeFilterByCompanies($query, $keys, $filterBy = 'id') { $prefix = 'fc_'; return $query->whereIn('id', function ($q) use ($prefix, $keys, $filterBy) { $q->from($prefix . 'companies') ->join( $prefix . 'subscriber_pivot', $prefix . 'subscriber_pivot.object_id', '=', $prefix . 'companies.id' ) ->where($prefix . 'subscriber_pivot.object_type', 'FluentCrm\App\Models\Company') ->whereIn($prefix . 'companies.' . $filterBy, $keys) ->groupBy($prefix . 'subscriber_pivot.subscriber_id') ->select($prefix . 'subscriber_pivot.subscriber_id'); }); } /** * Many2Many: Subscriber has many email sequences * @return \FluentCrm\Framework\Database\Orm\Relations\BelongsToMany */ public function sequences() { $class = '\FluentCampaign\App\Models\Sequence'; return $this->belongsToMany( $class, 'fc_sequence_tracker', 'subscriber_id', 'campaign_id' ) ->withoutGlobalScopes() ->withTimestamps(); } /** * Many2Many: Subscriber has many sequence trackers * @return \FluentCrm\Framework\Database\Orm\Relations\HasMany */ public function sequence_trackers() { $class = '\FluentCampaign\App\Models\SequenceTracker'; return $this->hasMany( $class, 'subscriber_id', 'id' ); } /** * Many2Many: Subscriber has many funnels * @return \FluentCrm\Framework\Database\Orm\Relations\BelongsToMany */ public function funnels() { $class = __NAMESPACE__ . '\Funnel'; return $this->belongsToMany( $class, 'fc_funnel_subscribers', 'subscriber_id', 'funnel_id' ) ->withoutGlobalScopes() ->withTimestamps(); } /** * Many2Many: Subscriber has many funnel subscribers * @return \FluentCrm\Framework\Database\Orm\Relations\hasMany */ public function funnel_subscribers() { return $this->hasMany( __NAMESPACE__ . '\FunnelSubscriber', 'subscriber_id', 'id' ); } /** * hasMany: Subscriber has many commerce items * @return \FluentCrm\Framework\Database\Orm\Relations\HasMany */ public function contact_commerce() { $class = '\FluentCampaign\App\Services\Commerce\ContactRelationModel'; return $this->hasMany($class, 'subscriber_id', 'id'); } /** * hasOne: Subscriber has a commerce for a specific provider * @return \FluentCrm\Framework\Database\Orm\Relations\hasOne */ public function commerce_by_provider() { $class = '\FluentCampaign\App\Services\Commerce\ContactRelationModel'; return $this->hasOne($class, 'subscriber_id', 'id'); } /** * hasOne: Subscriber has many commerce items * @return \FluentCrm\Framework\Database\Orm\Relations\HasMany */ public function contact_commerce_items() { $class = '\FluentCampaign\App\Services\Commerce\ContactRelationItemsModel'; return $this->hasMany($class, 'subscriber_id', 'id'); } public function scopeCommerceItemsItemIds($query, $provider, $method, $column, $values) { $query->whereHas('contact_commerce_items', $provider) ->{$method}($column, $values); } public function affiliate_wp() { $class = '\FluentCampaign\App\Services\Integrations\AffiliateWP\AffiliateWPModel'; return $this->hasOne($class, 'user_id', 'user_id'); } /** * Many2Many: Subscriber belongs to many lists * @return Model Collection */ public function lists() { $class = __NAMESPACE__ . '\Lists'; return $this->belongsToMany( $class, 'fc_subscriber_pivot', 'subscriber_id', 'object_id' ) ->wherePivot('object_type', $class) ->withPivot('object_type') ->orderBy('title', 'ASC') ->withTimestamps(); } /** * One2Many: Subscriber has to many SubscriberMeta * @return Model Collection */ public function meta() { $class = __NAMESPACE__ . '\SubscriberMeta'; return $this->hasMany( $class, 'subscriber_id', 'id' ); } /** * One2Many: Subscriber has to many SubscriberMeta * @return Model Collection */ public function custom_field_meta() { $class = __NAMESPACE__ . '\SubscriberMeta'; return $this->hasMany( $class, 'subscriber_id', 'id' )->where('object_type', '=', 'custom_field'); } /** * One2Many: Subscriber has to many Click Metrics * @return Model Collection */ public function urlMetrics() { $class = __NAMESPACE__ . '\CampaignUrlMetric'; return $this->hasMany( $class, 'subscriber_id', 'id' ); } /** * A subscriber has many campaign emails. * * @return \FluentCrm\Framework\Database\Orm\Relations\HasMany */ public function campaignEmails() { return $this->hasMany(CampaignEmail::class, 'subscriber_id', 'id'); } /** * A subscriber has many notes and activities. * * @return \FluentCrm\Framework\Database\Orm\Relations\HasMany */ public function notes() { return $this->hasMany(SubscriberNote::class, 'subscriber_id', 'id'); } /** * A subscriber has many tracking events. * * @return \FluentCrm\Framework\Database\Orm\Relations\HasMany */ public function trackingEvents() { return $this->hasMany(EventTracker::class, 'subscriber_id', 'id'); } /** * One2Many: Subscriber has to many custom fields value * @return array */ public function custom_fields() { $customFields = fluentcrm_get_custom_contact_fields(); if (!$customFields || !is_array($customFields)) { return []; } $keys = array_map(function ($item) { return $item['slug']; }, $customFields); if (!$keys) { return []; } $items = $this->custom_field_meta()->whereIn('key', $keys)->get(); $formattedValues = []; foreach ($items as $item) { $formattedValues[$item->key] = apply_filters('fluent_crm/modify_custom_field_value', $item->value); } return $formattedValues; } /** * Update Custom Field Values * @param $values array of custom values * @param bool $deleteOtherValues * @return array of updated values */ public function syncCustomFieldValues($values, $deleteOtherValues = true) { $emptyValues = array_filter($values, function ($value) { return $value === ''; }); $updateValues = []; if ($deleteOtherValues) { $deleteMetaKeys = array_keys($emptyValues); if ($deleteMetaKeys) { $this->custom_field_meta()->whereIn('key', $deleteMetaKeys)->delete(); foreach ($deleteMetaKeys as $key) { $updateValues[$key] = ''; } } } $newValues = array_filter($values, function ($value) { return $value !== ''; }); foreach ($newValues as $key => $value) { $exist = $this->meta()->where('key', $key)->first(); if ($exist) { if ($exist->value == $value) { continue; } $updateValues[$key] = $value; $exist->fill(['value' => $value])->save(); } else { $meta = new SubscriberMeta(); $meta->fill([ 'subscriber_id' => $this->id, 'object_type' => 'custom_field', 'key' => $key, 'value' => $value, 'created_by' => get_current_user_id() ]); $meta->save(); $updateValues[$key] = $value; } } if ($updateValues) { do_action('fluentcrm_contact_custom_data_updated', $newValues, $this, $updateValues); do_action('fluent_crm/contact_custom_data_updated', $newValues, $this, $updateValues); } return $updateValues; } public function stats() { return [ 'emails' => CampaignEmail::where('subscriber_id', $this->id) ->where('status', 'sent') ->count(), 'opens' => CampaignEmail::where('subscriber_id', $this->id) ->where('is_open', '>', 0) ->where('status', 'sent') ->count(), 'clicks' => CampaignEmail::where('subscriber_id', $this->id) ->whereNotNull('click_counter') ->where('status', 'sent') ->count() ]; } /** * Save the subscriber. * * @param array $data */ public static function store($data = []) { $model = static::create($data); if ($customValues = Arr::get($data, 'custom_values')) { $model->syncCustomFieldValues($customValues); } $tagIds = Arr::get($data, 'tags', []); if ($tagIds) { $model->attachTags($tagIds); } $listIds = Arr::get($data, 'lists', []); if ($listIds) { $model->attachLists($listIds); } $companyId = Arr::get($data, 'company_id'); if ($companyId && Helper::isExperimentalEnabled('company_module')) { $model->attachCompanies([$companyId]); } return $model; } /** * Get subscriber mappable fields. * * @return array */ public static function mappables() { $fields = [ 'prefix' => __('Name Prefix', 'fluent-crm'), 'first_name' => __('First Name', 'fluent-crm'), 'last_name' => __('Last Name', 'fluent-crm'), 'full_name' => __('Full Name', 'fluent-crm'), 'email' => __('Email', 'fluent-crm'), 'contact_type' => __('Contact Type', 'fluent-crm'), 'timezone' => __('Timezone', 'fluent-crm'), 'address_line_1' => __('Address Line 1', 'fluent-crm'), 'address_line_2' => __('Address Line 2', 'fluent-crm'), 'city' => __('City', 'fluent-crm'), 'state' => __('State', 'fluent-crm'), 'postal_code' => __('Postal Code', 'fluent-crm'), 'country' => __('Country', 'fluent-crm'), 'ip' => __('IP Address', 'fluent-crm'), 'phone' => __('Phone', 'fluent-crm'), 'source' => __('Source', 'fluent-crm'), 'date_of_birth' => __('Date of Birth (Y-m-d Format only)', 'fluent-crm') ]; if (Helper::isCompanyEnabled()) { $fields['company_id'] = __('Primary Company', 'fluent-crm'); } return $fields; } /** * Accessor to get dynamic photo attribute * @return string */ public function getPhotoAttribute() { if (!empty($this->attributes['avatar'])) { return $this->attributes['avatar']; } if (empty($this->attributes['email'])) { return ''; } $fallBack = ''; if (isset($this->attributes['first_name'])) { $fallBack = $this->attributes['first_name']; } if (isset($this->attributes['last_name'])) { $fallBack .= '+' . $this->attributes['last_name']; } return fluentcrmGravatar($this->attributes['email'], $fallBack); } /** * Accessor to get dynamic full_name attribute * @return string */ public function getFullNameAttribute() { $fname = isset($this->attributes['first_name']) ? $this->attributes['first_name'] : ''; $lname = isset($this->attributes['last_name']) ? $this->attributes['last_name'] : ''; return trim("{$fname} {$lname}"); } /** * Import csv/wpusers into subscribers * @param array $data * @param array $tags * @param array $lists * @param mixed $update string true/false or boolean true/false * @param string $newStatus status for the new subscribers * @param boolean $doubleOptin Send Double Optin Emails for new pending contacts * @param string $source Fallback Source for New Contacts * @return array affected records/collection */ public static function import($data, $tags, $lists, $update, $newStatus = '', $doubleOptin = false, $forceStatusChange = false, $source = '') { if (!defined('FLUENTCRM_DOING_BULK_IMPORT')) { define('FLUENTCRM_DOING_BULK_IMPORT', true); } ob_start(); $insertables = []; $updateables = []; $updatedModels = new Collection; $shouldUpdate = $update === 'true' || $update === true; $records = []; $uniqueEmails = []; foreach ($data as $index => $record) { $email = $record['email']; if (!filter_var($email, FILTER_VALIDATE_EMAIL) || in_array(strtolower($email), $uniqueEmails)) { unset($data[$index]); continue; } $uniqueEmails[] = strtolower($email); $record = self::explodeFullName($record); $data[$index] = $record; $records[] = $email; } $existingSubscribers = []; $oldSubscribers = static::whereIn('email', $records)->get(); foreach ($oldSubscribers as $model) { $existingSubscribers[strtolower($model->email)] = $model; } $strictStatuses = fluentcrm_strict_statues(); $newContactCustomFields = []; $newRecords = []; $skips = []; $newLists = []; $newTags = []; foreach ($data as $item) { $item['hash'] = md5($item['email']); $lowEmail = strtolower($item['email']); if (isset($existingSubscribers[$lowEmail])) { if (!$forceStatusChange && $newStatus && !in_array($newStatus, $strictStatuses)) { $item['status'] = $existingSubscribers[$lowEmail]->status; } else if ($newStatus) { $item['status'] = $newStatus; } unset($item['source']); $customValues = Arr::get($item, 'custom_values'); if ($shouldUpdate && $customValues) { $existingSubscribers[$lowEmail]->syncCustomFieldValues($customValues, false); } //if item has lists or tags that need to be processed then mapping to email if (Arr::get($item, 'lists')) { $existingListIdsOfUser = $existingSubscribers[$lowEmail]->lists()->get()->pluck('id')->toArray(); $newLists[$item['email']] = Helper::getNewAttachableLists(Arr::get($item, 'lists'), $existingListIdsOfUser, $lists); } if (Arr::get($item, 'tags')) { $existingTagIdsOfUser = $existingSubscribers[$lowEmail]->tags()->get()->pluck('id')->toArray(); $newTags[$item['email']] = Helper::getNewAttachableTags(Arr::get($item, 'tags'), $existingTagIdsOfUser, $tags); } unset($item['custom_values']); unset($item['id']); unset($item['created_at']); unset($item['lists']); unset($item['tags']); $item['updated_at'] = fluentCrmTimestamp(); $updateables[] = array_filter($item); } else { if (isset($newRecords[$item['email']])) { $skips[] = $item; continue; } $extraValues = [ 'created_at' => fluentCrmTimestamp() ]; if ($newStatus) { $extraValues['status'] = $newStatus; } if ($customValues = Arr::get($item, 'custom_values')) { $newContactCustomFields[$item['email']] = $customValues; } //if item has lists or tags that need to be processed then mapping to email if (Arr::get($item, 'lists')) { $newLists[$item['email']] = Arr::get($item, 'lists'); } if (Arr::get($item, 'tags')) { $newTags[$item['email']] = Arr::get($item, 'tags'); } $itemEmail = $item['email']; unset($item['custom_values']); unset($item['id']); unset($item['lists']); unset($item['tags']); if (empty($item['source']) && $source) { $item['source'] = $source; } $newRecords[$itemEmail] = 1; $insertables[] = array_filter(array_merge($item, $extraValues)); } } $insertedModels = []; if ($insertables) { foreach ($insertables as $insertable) { $attachableTags = $tags; $attachableLists = $lists; $insertedModel = self::create($insertable); if ($newContactCustomFields) { if (isset($newContactCustomFields[$insertedModel->email])) { $insertedModel->syncCustomFieldValues( $newContactCustomFields[$insertedModel->email], false ); } } //checking insertable email has tags, lists that need to be created or is already have if (!empty($newTags[$insertedModel->email])) { $newlyCreateTagIds = Helper::createNewTags($newTags[$insertedModel->email]); $attachableTags = array_merge($tags, $newlyCreateTagIds); } if (!empty($newLists[$insertedModel->email])) { $newlyCreateListIds = Helper::createNewLists($newLists[$insertedModel->email]); $attachableLists = array_merge($lists, $newlyCreateListIds); } if ($attachableTags || $attachableLists || $doubleOptin) { $attachableTags && $insertedModel->attachTags($attachableTags); $attachableLists && $insertedModel->attachLists($attachableLists); if ($doubleOptin && $insertedModel->status == 'pending') { $insertedModel->sendDoubleOptinEmail(); } } if (!empty($insertable['company_id']) && Helper::isCompanyEnabled()) { $insertedModel->attachCompanies([$insertable['company_id']]); } /* * @deprecated since 2.8.0. Use fluent_crm/contact_created instead */ do_action('fluentcrm_contact_created', $insertedModel); do_action('fluent_crm/contact_created', $insertedModel); $insertedModels[] = $insertedModel; } } if ($shouldUpdate) { foreach ($updateables as $updateable) { $existingModel = $existingSubscribers[strtolower($updateable['email'])]; $oldStatus = $existingModel->status; $existingModel->fill($updateable); $updateData = $existingModel->getDirty(); if ($updateData) { $existingModel->save(); if (!empty($updateable['company_id']) && Helper::isCompanyEnabled()) { $existingModel->attachCompanies([$updateable['company_id']]); } if (!empty($updateable['status']) && $updateable['status'] != $oldStatus) { $newStatus = $updateable['status']; do_action('fluent_crm/subscriber_status_changed', $existingModel, $oldStatus, $newStatus); do_action('fluentcrm_subscriber_status_to_' . $newStatus, $existingModel, $oldStatus); } //attaching new lists, tags to subscriber if (!empty($newLists[$updateable['email']])) { $existingModel->attachLists($newLists[$updateable['email']]); } if (!empty($newTags[$updateable['email']])) { $existingModel->attachTags($newTags[$updateable['email']]); } do_action('fluentcrm_contact_updated', $existingModel, $updateData); do_action('fluent_crm/contact_updated', $existingModel, $updateData); } $updatedModels->push($existingModel); } } // Syncing Tags & Lists if ($tags || $lists || $doubleOptin) { if ($shouldUpdate) { foreach ($oldSubscribers as $model) { $tags && $model->attachTags($tags); $lists && $model->attachLists($lists); } } } do_action('fluentcrm_contacts_imported_bulk', $insertedModels); do_action('fluentcrm_contacts_updated_bulk', $updatedModels); $errors = ob_get_clean(); return [ 'inserted' => $insertedModels, 'updated' => $updatedModels, 'skips' => $skips, 'errors' => $errors ]; } public function updateOrCreate($data, $forceUpdate = false, $deleteOtherValues = false, $sync = false) { $subscriberData = static::explodeFullName($data); $subscriberData = array_filter(Arr::only($subscriberData, $this->getFillable())); $tags = Arr::get($data, 'tags', []); $lists = Arr::get($data, 'lists', []); $companies = Arr::get($data, 'companies', []); $exist = static::where('email', $subscriberData['email'])->first(); if (empty($subscriberData['user_id'])) { $user = get_user_by('email', $subscriberData['email']); if ($user) { $subscriberData['user_id'] = $user->ID; } } $isNew = true; $oldStatus = ''; if ($exist) { $isNew = false; $oldStatus = $exist->status; } if (!empty($data['status'])) { $status = $data['status']; if ($forceUpdate) { $subscriberData['status'] = $status; } else if ($exist && $exist->status == 'subscribed') { unset($subscriberData['status']); } else if ($exist && in_array($exist->status, ['bounced', 'complained', 'spammed'])) { unset($subscriberData['status']); } else { $subscriberData['status'] = $status; } if ($status == 'unsubscribed') { $subscriberData['status'] = 'unsubscribed'; } } $isSubscribed = false; if (($exist && $exist->status != 'subscribed') && (!empty($subscriberData['status']) && $subscriberData['status'] === 'subscribed')) { $isSubscribed = true; } else if (!$exist && (!empty($subscriberData['status']) && $subscriberData['status'] === 'subscribed')) { $isSubscribed = true; } $dirtyFields = []; if ($exist) { $oldEmail = $exist->email; $exist->fill($subscriberData); $dirtyFields = $exist->getDirty(); if ($dirtyFields) { $exist->save(); if (isset($dirtyFields['email'])) { do_action('fluent_crm/contact_email_changed', $exist, $oldEmail); } } } else { if (!isset($subscriberData['created_at'])) { $subscriberData['created_at'] = current_time('mysql'); } $exist = static::create($subscriberData); $exist = $this->find($exist->id); $exist->wasRecentlyCreated = true; } $customFieldsChanges = []; if ($customValues = Arr::get($data, 'custom_values', [])) { $customFieldsChanges = $exist->syncCustomFieldValues($customValues, $deleteOtherValues); } /* * TODO: investigate this attachTags and attachLists method. for Masiur */ // Syncing Lists if ($lists) { $exist->attachLists($lists); } // Syncing Tags if ($tags) { $exist->attachTags($tags); } if (Helper::isCompanyEnabled()) { $companyId = $exist->company_id; if (empty($companyId)) { // Syncing Companies $companies && $exist->attachCompanies($companies); } else { $exist->attachCompanies([$companyId]); } } if ($detachTags = Arr::get($data, 'detach_tags', [])) { $exist->detachTags($detachTags); } if ($detachLists = Arr::get($data, 'detach_lists', [])) { $exist->detachLists($detachLists); } if ($isNew) { do_action('fluentcrm_contact_created', $exist); // @deprecated since 2.8.0. Use fluent_crm/contact_created instead do_action('fluent_crm/contact_created', $exist); } else if ($dirtyFields || $customFieldsChanges) { do_action('fluentcrm_contact_updated', $exist, $dirtyFields); // @deprecated since 2.8.0. Use fluent_crm/contact_updated instead do_action('fluent_crm/contact_updated', $exist, $dirtyFields); } if ($isSubscribed && $exist->status == 'subscribed') { if (!$isNew) { do_action('fluent_crm/subscriber_status_changed', $exist, $oldStatus, $exist->status); } do_action('fluentcrm_subscriber_status_to_subscribed', $exist, $oldStatus); } return $exist; } public function sendDoubleOptinEmail() { $lastDoubleOptin = fluentcrm_get_subscriber_meta($this->id, '_last_double_optin_timestamp'); if ($lastDoubleOptin && (time() - $lastDoubleOptin < 150)) { return false; } else { fluentcrm_update_subscriber_meta($this->id, '_last_double_optin_timestamp', time()); } return (new Handler())->sendDoubleOptInEmail($this); } public static function explodeFullName($record) { if (!empty($record['first_name']) || !empty($record['last_name'])) { return $record; } if (!empty($record['full_name'])) { $fullNameArray = explode(' ', $record['full_name']); $record['first_name'] = array_shift($fullNameArray); if ($fullNameArray) { $record['last_name'] = implode(' ', $fullNameArray); } unset($record['full_name']); } return $record; } public function attachLists($listIds) { if (!$listIds) { return $this; } // Guard against attaching to an unsaved Subscriber (id = 0 or unset). // Without this, INSERT IGNORE would happily write a (0, X, 'Lists') // garbage row and the contact_added_to_lists action would fire with // $this->id = 0. Sanitize is also skipped because sanitizeListIds() can // create new lists as a side effect, which we don't want to do for an // invalid subscriber. if (empty($this->id)) { return $this; } $listIds = Sanitize::sanitizeListIds($listIds); $listIds = array_filter(array_map('intval', $listIds)); if (!$listIds) { return $this; } global $wpdb; $pivotTable = $wpdb->prefix . 'fc_subscriber_pivot'; $objectType = 'FluentCrm\App\Models\Lists'; // Per-row INSERT IGNORE. The composite unique key on // (subscriber_id, object_id, object_type) added in the SubscriberPivot // migration makes this race-safe: when two concurrent integrations // (e.g. WooCommerce + WP Fusion + LearnDash hooks all firing on the // same enrollment) call attachLists() with the same pair, the second // INSERT IGNORE gets rows_affected = 0 and we don't fire the // contact_added_to_lists action for that ID — preventing the // duplicate-event leak the customer was seeing. // Timestamps use current_time('mysql') (WordPress site timezone) to // match the ORM's freshTimestamp() convention used by historical rows. $now = current_time('mysql'); $newListIds = []; foreach ($listIds as $listId) { $affected = $wpdb->query($wpdb->prepare( "INSERT IGNORE INTO {$pivotTable} (subscriber_id, object_id, object_type, created_at, updated_at) VALUES (%d, %d, %s, %s, %s)", $this->id, $listId, $objectType, $now, $now )); // $wpdb->query() returns false on errors like deadlocks or lock-wait // timeouts. PHP's `false > 0` is false, so without this check the // failure would be silently treated as "row already existed" and we // wouldn't fire contact_added_to_lists even when the row may have // ultimately been written. Log and skip to surface the issue. if ($affected === false) { Helper::debugLog('Subscriber::attachLists pivot insert failed', $wpdb->last_error, 'error'); continue; } if ($affected > 0) { $newListIds[] = $listId; } } // Always refresh the in-memory relationship so callers reusing this // Subscriber instance see post-write state, even on the no-op path. // Matches the original ORM-driven attach() behavior, which loaded // upfront for the diff and reloaded after write. $this->load('lists'); if ($newListIds) { fluentcrm_contact_added_to_lists($newListIds, $this); do_action('fluent_crm/contact_added_to_lists', $this, $newListIds); } return $this; } public function attachTags($tagIds) { if (!$tagIds) { return $this; } // Guard against attaching to an unsaved Subscriber — see attachLists(). if (empty($this->id)) { return $this; } $tagIds = Sanitize::sanitizeTagIds($tagIds); $tagIds = array_filter(array_map('intval', $tagIds)); if (!$tagIds) { return $this; } global $wpdb; $pivotTable = $wpdb->prefix . 'fc_subscriber_pivot'; $objectType = 'FluentCrm\App\Models\Tag'; // Per-row INSERT IGNORE — see attachLists() for the rationale. $now = current_time('mysql'); $newTagIds = []; foreach ($tagIds as $tagId) { $affected = $wpdb->query($wpdb->prepare( "INSERT IGNORE INTO {$pivotTable} (subscriber_id, object_id, object_type, created_at, updated_at) VALUES (%d, %d, %s, %s, %s)", $this->id, $tagId, $objectType, $now, $now )); if ($affected === false) { Helper::debugLog('Subscriber::attachTags pivot insert failed', $wpdb->last_error, 'error'); continue; } if ($affected > 0) { $newTagIds[] = $tagId; } } // Always refresh the relation — see attachLists() for rationale. $this->load('tags'); if ($newTagIds) { fluentcrm_contact_added_to_tags($newTagIds, $this); do_action('fluent_crm/contact_added_to_tags', $this, $newTagIds); } return $this; } public function attachCompanies($companyIds) { if (!$companyIds) { return $this; } // Guard against attaching to an unsaved Subscriber — see attachLists(). if (empty($this->id)) { return $this; } $companyIds = array_filter(array_map('intval', $companyIds)); if (!$companyIds) { return $this; } global $wpdb; $pivotTable = $wpdb->prefix . 'fc_subscriber_pivot'; $objectType = 'FluentCrm\App\Models\Company'; // Per-row INSERT IGNORE — see attachLists() for the rationale. $now = current_time('mysql'); $newCompanyIds = []; foreach ($companyIds as $companyId) { $affected = $wpdb->query($wpdb->prepare( "INSERT IGNORE INTO {$pivotTable} (subscriber_id, object_id, object_type, created_at, updated_at) VALUES (%d, %d, %s, %s, %s)", $this->id, $companyId, $objectType, $now, $now )); if ($affected === false) { Helper::debugLog('Subscriber::attachCompanies pivot insert failed', $wpdb->last_error, 'error'); continue; } if ($affected > 0) { $newCompanyIds[] = $companyId; } } // Always refresh the relation — see attachLists() for rationale. $this->load('companies'); if ($newCompanyIds) { fluentcrm_contact_added_to_companies($newCompanyIds, $this); } return $this; } public function detachLists($listIds) { if (!$listIds) { return $this; } // Guard against detaching from an unsaved Subscriber — defensive, same // pattern as the attach methods. With $this->id = 0 the SELECT would // match any garbage rows the migration is supposed to have cleaned up. if (empty($this->id)) { return $this; } $listIds = Sanitize::sanitizeListIds($listIds, false); $listIds = array_filter(array_map('intval', $listIds)); if (!$listIds) { return $this; } global $wpdb; $pivotTable = $wpdb->prefix . 'fc_subscriber_pivot'; $objectType = 'FluentCrm\App\Models\Lists'; // Fresh DB read — bypass any stale relationship cached on $this — so we // only consider list IDs that actually exist for this subscriber right now. $placeholders = implode(',', array_fill(0, count($listIds), '%d')); $existingListIds = $wpdb->get_col($wpdb->prepare( "SELECT object_id FROM {$pivotTable} WHERE subscriber_id = %d AND object_type = %s AND object_id IN ({$placeholders})", array_merge([$this->id, $objectType], $listIds) )); $existingListIds = array_map('intval', $existingListIds); $validListIds = array_values(array_intersect($listIds, $existingListIds)); if (!$validListIds) { return $this; } // Per-row DELETE so a concurrent detachLists() racing us against the // same (subscriber, list) pair can be distinguished via rows_affected. // The first DELETE removes the row and reports affected = 1; the // second reports 0 and we skip firing the action — preventing the // double-fire that would happen if we trusted our pre-DELETE diff. $removedListIds = []; foreach ($validListIds as $listId) { $affected = $wpdb->query($wpdb->prepare( "DELETE FROM {$pivotTable} WHERE subscriber_id = %d AND object_type = %s AND object_id = %d", $this->id, $objectType, $listId )); if ($affected === false) { Helper::debugLog('Subscriber::detachLists pivot delete failed', $wpdb->last_error, 'error'); continue; } if ($affected > 0) { $removedListIds[] = $listId; } } // Always refresh the relation — see attachLists() for rationale. $this->load('lists'); if ($removedListIds) { fluentcrm_contact_removed_from_lists($removedListIds, $this); do_action('fluent_crm/contact_removed_from_lists', $this, $removedListIds); } return $this; } public function detachTags($tagsIds) { if (!$tagsIds) { return $this; } // Guard against detaching from an unsaved Subscriber — see detachLists(). if (empty($this->id)) { return $this; } $tagsIds = Sanitize::sanitizeTagIds($tagsIds, false); $tagsIds = array_filter(array_map('intval', $tagsIds)); if (!$tagsIds) { return $this; } global $wpdb; $pivotTable = $wpdb->prefix . 'fc_subscriber_pivot'; $objectType = 'FluentCrm\App\Models\Tag'; // Fresh DB read — see detachLists() for rationale. $placeholders = implode(',', array_fill(0, count($tagsIds), '%d')); $existingTagIds = $wpdb->get_col($wpdb->prepare( "SELECT object_id FROM {$pivotTable} WHERE subscriber_id = %d AND object_type = %s AND object_id IN ({$placeholders})", array_merge([$this->id, $objectType], $tagsIds) )); $existingTagIds = array_map('intval', $existingTagIds); $validTagIds = array_values(array_intersect($tagsIds, $existingTagIds)); if (!$validTagIds) { return $this; } // Per-row DELETE with rows_affected check — see detachLists(). $removedTagIds = []; foreach ($validTagIds as $tagId) { $affected = $wpdb->query($wpdb->prepare( "DELETE FROM {$pivotTable} WHERE subscriber_id = %d AND object_type = %s AND object_id = %d", $this->id, $objectType, $tagId )); if ($affected === false) { Helper::debugLog('Subscriber::detachTags pivot delete failed', $wpdb->last_error, 'error'); continue; } if ($affected > 0) { $removedTagIds[] = $tagId; } } // Always refresh the relation — see attachLists() for rationale. $this->load('tags'); if ($removedTagIds) { fluentcrm_contact_removed_from_tags($removedTagIds, $this); do_action('fluent_crm/contact_removed_from_tags', $this, $removedTagIds); } return $this; } public function detachCompanies($companyIds) { if (!$companyIds) { return $this; } // Guard against detaching from an unsaved Subscriber — see detachLists(). if (empty($this->id)) { return $this; } $companyIds = array_filter(array_map('intval', $companyIds)); if (!$companyIds) { return $this; } global $wpdb; $pivotTable = $wpdb->prefix . 'fc_subscriber_pivot'; $objectType = 'FluentCrm\App\Models\Company'; // Fresh DB read — see detachLists() for rationale. $placeholders = implode(',', array_fill(0, count($companyIds), '%d')); $existingCompanyIds = $wpdb->get_col($wpdb->prepare( "SELECT object_id FROM {$pivotTable} WHERE subscriber_id = %d AND object_type = %s AND object_id IN ({$placeholders})", array_merge([$this->id, $objectType], $companyIds) )); $existingCompanyIds = array_map('intval', $existingCompanyIds); $validCompanyIds = array_values(array_intersect($companyIds, $existingCompanyIds)); if (!$validCompanyIds) { return $this; } // Per-row DELETE with rows_affected check — see detachLists(). $removedCompanyIds = []; foreach ($validCompanyIds as $companyId) { $affected = $wpdb->query($wpdb->prepare( "DELETE FROM {$pivotTable} WHERE subscriber_id = %d AND object_type = %s AND object_id = %d", $this->id, $objectType, $companyId )); if ($affected === false) { Helper::debugLog('Subscriber::detachCompanies pivot delete failed', $wpdb->last_error, 'error'); continue; } if ($affected > 0) { $removedCompanyIds[] = $companyId; } } // Always refresh the relation — see attachLists() for rationale. $this->load('companies'); if ($removedCompanyIds) { fluentcrm_contact_removed_from_companies($removedCompanyIds, $this); } return $this; } public function unsubscribeReason($metaKey = 'unsubscribe_reason') { return fluentcrm_get_subscriber_meta($this->id, $metaKey, ''); } public function unsubscribeReasonDate($metaKey = 'unsubscribe_reason') { $item = SubscriberMeta::where('key', $metaKey) ->where('subscriber_id', $this->id) ->first(); if ($item) { return (string)$item->updated_at; } return ''; } public function hasAnyTagId($tagIds) { if (!$tagIds || !is_array($tagIds)) { return false; } $tagIds = Sanitize::sanitizeTagIds($tagIds, false); $this->load('tags'); foreach ($this->tags as $tag) { if (in_array($tag->id, $tagIds)) { return true; } } return false; } public function hasAnyListId($listIds) { if (!$listIds || !is_array($listIds)) { return false; } $listIds = Sanitize::sanitizeListIds($listIds, false); $this->load('lists'); foreach ($this->lists as $list) { if (in_array($list->id, $listIds)) { return true; } } return false; } public function updateMeta($metaKey, $metaValue, $objectType) { $exist = $this->meta() ->where('key', $metaKey) ->where('object_type', $objectType) ->first(); if ($exist) { $exist->value = $metaValue; $exist->save(); return true; } $this->meta()->create([ 'key' => $metaKey, 'object_type' => $objectType, 'value' => $metaValue ]); return true; } public function getMeta($metaKey, $objectType) { $exist = $this->meta() ->where('key', $metaKey) ->where('object_type', $objectType) ->first(); if ($exist) { return $exist->value; } return false; } /** * Parse filter to set proper operator and value for the filter query for date operators * * @param array $filter * @return array */ public static function filterParser($filter) { switch ($filter['operator']) { case 'before': $filter['operator'] = '<'; if (!empty($filter['value']) && strlen($filter['value']) < 11) { $filter['value'] = $filter['value'] . ' 00:00:00'; } break; case 'after': $filter['operator'] = '>'; if (!empty($filter['value']) && strlen($filter['value']) < 11) { $filter['value'] = $filter['value'] . ' 00:00:00'; } break; case 'date_equal': case 'contains': $filter['operator'] = 'LIKE'; $filter['value'] = '%' . $filter['value'] . '%'; break; case 'not_contains': $filter['operator'] = 'NOT LIKE'; $filter['value'] = '%' . $filter['value'] . '%'; break; case 'days_before': $daysToSeconds = intval($filter['value']) * 24 * 60 * 60; $filter['operator'] = '<'; $filter['value'] = gmdate('Y-m-d', current_time('timestamp') - $daysToSeconds); break; case 'days_within': $daysToSeconds = intval($filter['value']) * 24 * 60 * 60; $filter['operator'] = 'BETWEEN'; $filter['value'] = [ gmdate('Y-m-d 00:00:01', current_time('timestamp') - $daysToSeconds), gmdate('Y-m-d') . ' 23:59:59' ]; break; } return $filter; } public static function applyGeneralFilterQuery($query, $filter, $referenceColumn = 'value') { $exactOperators = ['=', '!=', '>', '<']; $operator = self::parseCustomFieldsFilterOperator($filter); if (in_array($operator, $exactOperators)) { if ($operator == '>' || $operator == '<') { $filter['value'] = (float)$filter['value']; } else { $filter['value'] = sanitize_text_field($filter['value']); } $query->where($referenceColumn, $operator, $filter['value']); } else { $filter = self::filterParser($filter); if ($filter['operator'] != $operator) { $newOperator = $filter['operator']; if ($newOperator == 'BETWEEN') { $query->whereBetween($referenceColumn, $filter['value']); } elseif ($newOperator == 'LIKE' || $newOperator == 'NOT LIKE') { $query->where($referenceColumn, $newOperator, '%' . $filter['value'] . '%'); } else { // Date operators (before/after/date_equal/days_before/days_within) are // already normalized by filterParser() into '<' / '>' with a datetime // string value. A plain where() is correct here — the previous // whereTimestamp() call was a phantom method that the WPFluent // dynamic-where __call magic silently rewrote to // `WHERE timestamp = ''`, producing a SQL error. $query->where($referenceColumn, $newOperator, $filter['value']); } } else { $filter['value'] = sanitize_text_field($filter['value']); $query->where($referenceColumn, $operator, '%' . $filter['value'] . '%'); } } return $query; } /** * Dynamically build relation filter query for the Subscriber * model. It handles purchase, lists, tags relations. * * @param string $relation * @param \FluentCrm\Framework\Database\Orm\Builder|\FluentCrm\Framework\Database\Query\Builder $query * @param string $method * @param string $subMethod * @param string $subField * @param array $filter * @param string $provider * @return \FluentCrm\Framework\Database\Orm\Builder|\FluentCrm\Framework\Database\Query\Builder */ public static function buildRelationFilterQuery($relation, $query, $method, $subMethod, $subField, $filter, $provider = false) { if (in_array($filter['operator'], ['in_all', 'not_in_all'])) { foreach ($filter['value'] as $item) { $query = static::buildRelationFilterQuery($relation, $query, $method, $subMethod, $subField, ['value' => $item, 'operator' => ''], $provider); } } else { $query = $query->{$method}($relation, function ($relationQuery) use ($subMethod, $subField, $filter, $provider) { $relationQuery = $relationQuery->{$subMethod}($subField, $filter['value']); if ($provider) { $relationQuery = $relationQuery->where('provider', $provider); } return $relationQuery; }); } return $query; } /** * Dynamically build relation filter query for the Subscriber model. * It handles taxonomy query for subscribers purchase history mainly * * @param string $primaryRelation * @param string $childRelation * @param \FluentCrm\Framework\Database\Orm\Builder|\FluentCrm\Framework\Database\Query\Builder $mainQuery * @param string $method * @param string $subField * @param array $itemIds * @param $checkAll bool * @param string $provider * @return \FluentCrm\Framework\Database\Orm\Builder|\FluentCrm\Framework\Database\Query\Builder */ public static function buildChildRelationFilterQuery($primaryRelation, $childRelation, $mainQuery, $method, $subField, $itemIds, $checkAll = false, $provider = false) { if (!$checkAll) { return $mainQuery->{$method}($primaryRelation, function ($query) use ($itemIds, $provider, $childRelation, $subField) { $query->whereHas($childRelation, function ($q) use ($itemIds, $subField) { $q->whereIn($subField, $itemIds); return $q; }); if ($provider) { $query->where('provider', $provider); } return $query; }); } return $mainQuery->{$method}($primaryRelation, function ($query) use ($itemIds, $provider, $childRelation, $subField) { $query->whereHas($childRelation, function ($q) use ($itemIds, $subField) { $q->distinct()->whereIn($subField, $itemIds); }, '=', count($itemIds)); if ($provider) { $query->where('provider', $provider); } return $query; }); } /** * Builds purchase provider related filter query. It handles woo, edd filter now. * * @param \FluentCrm\Framework\Database\Orm\Builder|\FluentCrm\Framework\Database\Query\Builder $query * @param array $filters * @param string $provider * @return \FluentCrm\Framework\Database\Orm\Builder|\FluentCrm\Framework\Database\Query\Builder */ public static function providerQueryBuilder($query, $filters, $provider = 'woo') { $filters = array_reduce($filters, function ($carry, $filter) { if ($filter['property'] == 'purchased_items') { $carry['contactRelationsItems'][] = $filter; } else if (in_array($filter['property'], ['purchased_categories', 'purchased_tags', 'purchased_groups'])) { $filter['property'] = 'commerce_taxonomies'; $carry['itemTaxonomyRelations'][] = $filter; } elseif ($filter['property'] == 'commerce_coupons') { $carry['contactCommerceIn'][] = $filter; } elseif ($filter['property'] == 'commerce_exist') { $filter['method'] = 'whereHas'; if ($filter['operator'] == 'not_exist') { $filter['method'] = 'whereDoesntHave'; } $carry['contactCommerceCheck'][] = $filter; } else if ($filter['property'] == 'variation_purchased') { $filter['property'] = 'item_sub_id'; $filter['method'] = 'whereHas'; if ($filter['operator'] == 'not_exist') { $filter['method'] = 'whereDoesntHave'; } if (is_array($filter['value']) && $filter['value']) { $formattedVariationIds = []; foreach ($filter['value'] as $value) { $ids = explode('||', $value); if ($ids && count($ids) == 2) { $formattedVariationIds[] = (int)$ids[1]; } } $formattedVariationIds = array_values(array_unique($formattedVariationIds)); if ($formattedVariationIds) { $filter['value'] = $formattedVariationIds; $carry['contactSubFieldItems'][] = $filter; } } } else { $carry['contactRelations'][] = $filter; } return $carry; }, []); if (array_key_exists('contactRelations', $filters)) { $query->whereHas('contact_commerce', function ($contactCommerceQuery) use ($filters, $provider) { foreach ($filters['contactRelations'] as $filter) { $filter = static::filterParser($filter); if ($filter['operator'] == 'BETWEEN') { $contactCommerceQuery->whereBetween($filter['property'], $filter['value']); } else { $contactCommerceQuery->where($filter['property'], $filter['operator'], $filter['value']); } } return $contactCommerceQuery->where('provider', $provider); }); } if (array_key_exists('contactRelationsItems', $filters)) { foreach ($filters['contactRelationsItems'] as $filter) { if ($filter['operator'] == 'not_in_all') { $query = static::buildChildRelationFilterQuery('commerce_by_provider', 'items', $query, 'whereDoesntHave', 'item_id', $filter['value'], true, $provider); } else { list($method, $subMethod) = static::parseRelationalFilterQueryMethods($filter); $query = static::buildRelationFilterQuery('contact_commerce_items', $query, $method, $subMethod, 'item_id', $filter, $provider); } } } if (array_key_exists('contactSubFieldItems', $filters)) { foreach ($filters['contactSubFieldItems'] as $filter) { $query = static::buildRelationFilterQuery('contact_commerce_items', $query, $filter['method'], 'whereIn', $filter['property'], $filter, $provider); } } if (array_key_exists('itemTaxonomyRelations', $filters)) { $childTaxMaps = [ 'in' => 'whereHas', 'not_in' => 'whereDoesntHave', 'in_all' => 'whereHas', 'not_in_all' => 'whereDoesntHave' ]; foreach ($filters['itemTaxonomyRelations'] as $filter) { $operator = $filter['operator']; if (!isset($childTaxMaps[$operator]) || empty($filter['value'])) { continue; } $method = $childTaxMaps[$operator]; $checkAll = in_array($operator, ['in_all', 'not_in_all']); if ($checkAll) { $query = static::buildChildRelationFilterQuery('commerce_by_provider', 'taxonomies', $query, $method, 'term_taxonomy_id', $filter['value'], $checkAll, $provider); } else { $query = static::buildChildRelationFilterQuery('contact_commerce_items', 'taxonomies', $query, $method, 'term_taxonomy_id', $filter['value'], $checkAll, $provider); } } } if (array_key_exists('contactCommerceIn', $filters)) { foreach ($filters['contactCommerceIn'] as $filter) { $filter['value'] = (array)$filter['value']; $method = in_array($filter['operator'], ['in', 'in_all']) ? 'whereHas' : 'whereDoesntHave'; if (in_array($filter['operator'], ['in', 'not_in'])) { $query->{$method}('contact_commerce', function ($contactCommerceQuery) use ($filter, $provider) { $contactCommerceQuery ->where('provider', $provider) ->where(function ($query) use ($filter) { $firstVal = array_shift($filter['value']); $operator = 'LIKE'; $query->where($filter['property'], $operator, '%' . $firstVal . '%'); foreach ($filter['value'] as $value) { $query->orWhere($filter['property'], $operator, '%' . $value . '%'); } }); }); } else { foreach ($filter['value'] as $value) { $query->{$method}('contact_commerce', function ($contactCommerceQuery) use ($filter, $value, $provider) { $contactCommerceQuery ->where('provider', $provider) ->where($filter['property'], 'LIKE', '%' . $value . '%'); }); } } } } if (array_key_exists('contactCommerceCheck', $filters)) { foreach ($filters['contactCommerceCheck'] as $filter) { $method = $filter['method']; $query->{$method}('contact_commerce', function ($q) use ($provider) { $q->where('provider', $provider); }); } } return $query; } public function buildSearchableQuery($query, $search, $operator = 'LIKE') { $fields = $this->searchable; $query->where(function ($query) use ($fields, $search, $operator) { $query->where(array_shift($fields), $operator, $search); $nameArray = explode(' ', $search); if (count($nameArray) >= 2) { $query->orWhere(function ($q) use ($nameArray, $operator) { $firstName = array_shift($nameArray); $lastName = implode(' ', $nameArray); $q->where('first_name', $operator, $firstName); $q->where('last_name', $operator, $lastName); }); } foreach ($fields as $field) { $query->orWhere($field, $operator, $search); } }); return $query; } /** * @param \FluentCrm\Framework\Database\Orm\Builder|\FluentCrm\Framework\Database\Query\Builder $query * @param array $filters * @return \FluentCrm\Framework\Database\Orm\Builder|\FluentCrm\Framework\Database\Query\Builder */ public function buildGeneralPropertiesFilterQuery($query, $filters) { foreach ($filters as $filter) { $operator = $filter['operator']; $searchTerm = $filter['value']; if ($filter['operator'] == 'contains') { if (is_array($filter['value'])) { continue; } $operator = 'LIKE'; $searchTerm = '%' . $filter['value'] . '%'; } elseif ($filter['operator'] == 'not_contains') { if (is_array($filter['value'])) { continue; } $operator = 'NOT LIKE'; $searchTerm = '%' . $filter['value'] . '%'; } $dateFields = ['created_at', 'last_activity', 'date_of_birth']; if (in_array($filter['property'], $dateFields)) { if (empty($filter['value'])) { continue; } // created_at/last_activity are TIMESTAMP and date_of_birth is DATE. // Comparing those columns to an empty string forces MySQL to coerce // '' into a TIMESTAMP/DATE and throws "Incorrect TIMESTAMP value: ''". // whereNotNull + != '0000-00-00' is enough — empty strings cannot // legitimately be stored in these typed columns. $query = $query->where(function ($q) use ($filter) { $q->whereNotNull($filter['property']) ->where($filter['property'], '!=', '0000-00-00'); }); $query = self::applyGeneralFilterQuery($query, $filter, $filter['property']); } else if ($filter['property'] == 'search') { $query = $this->buildSearchableQuery($query, $searchTerm, $operator); } else if ($operator == 'in') { if (!is_array($searchTerm)) { $searchTerm = (array)$searchTerm; } if ($searchTerm) { $query = $query->whereIn($filter['property'], $searchTerm); } } else if ($operator == 'not_in') { if (!is_array($searchTerm)) { $searchTerm = (array)$searchTerm; } if ($searchTerm) { $query = $query->whereNotIn($filter['property'], $searchTerm); } } else if ($operator == 'is_null') { $query = $query->where(function ($q) use ($filter) { return $q->whereNull($filter['property']) ->orWhere($filter['property'], '=', ''); }); } else if ($operator == 'not_null') { $query = $query->where(function ($q) use ($filter) { return $q->whereNotNull($filter['property']) ->where($filter['property'], '!=', ''); }); } else { $query = $query->where($filter['property'], $operator, $searchTerm); } } return $query; } /** * @param array $filter * @return string[] */ public static function parseRelationalFilterQueryMethods($filter) { // default operator = in $method = 'whereHas'; $subMethod = 'whereIn'; switch ($filter['operator']) { case 'not_in': $method = 'whereDoesntHave'; $subMethod = 'whereIn'; break; case 'in_all': $method = 'whereHas'; $subMethod = 'where'; break; case 'not_in_all': $method = 'whereDoesntHave'; $subMethod = 'where'; break; } return [$method, $subMethod]; } /** * @param \FluentCrm\Framework\Database\Orm\Builder|\FluentCrm\Framework\Database\Query\Builder $query * @param array $filters * @return \FluentCrm\Framework\Database\Orm\Builder|\FluentCrm\Framework\Database\Query\Builder */ public function buildSegmentFilterQuery($query, $filters) { foreach ($filters as $filter) { if (empty($filter['value'])) { continue; } $prop = $filter['property']; if (in_array($prop, ['tags', 'lists', 'companies'])) { if ($filter['operator'] == 'not_in_all') { $query->has($prop, '<', count($filter['value']), 'and', function ($query) use ($filter) { $query->whereIn('object_id', $filter['value']); }); } else { list($method, $subMethod) = static::parseRelationalFilterQueryMethods($filter); $query = static::buildRelationFilterQuery($filter['property'], $query, $method, $subMethod, 'object_id', $filter); } } else if ($prop == 'user_role') { $userRole = esc_sql($filter['value']); $operator = $filter['operator']; $method = ($operator == 'in' || $operator == 'contains') ? 'whereHas' : 'whereDoesntHave'; $query = $query->{$method}('user', function ($userQuery) use ($userRole) { return $userQuery->whereExists(function ($subQuery) use ($userRole) { global $wpdb; return $subQuery->select(fluentCrmDb()->raw(1)) ->from('usermeta') ->whereRaw("{$wpdb->prefix}usermeta.user_id = {$wpdb->prefix}users.ID") ->where('usermeta.meta_key', '=', $wpdb->prefix . 'capabilities') ->where('usermeta.meta_value', 'LIKE', '%"' . $userRole . '"%'); }); }); } else if ($prop == 'company_industry') { $operator = $filter['operator']; $queryOperator = '>='; if ($operator == 'not_in') { $queryOperator = '<'; } $query = $query->has('companies', $queryOperator, 1, 'and', function ($q) use ($filter) { $values = (array)$filter['value']; $q->whereIn('industry', $values); }); } else if ($prop == 'company_type') { $queryOperator = '>='; if ($operator == 'not_in') { $queryOperator = '<'; } $query = $query->has('companies', $queryOperator, 1, 'and', function ($q) use ($filter) { $values = (array)$filter['value']; $q->whereIn('type', $values); }); } else { $operator = $filter['operator']; $method = ($operator == 'in' || $operator == 'contains') ? 'whereIn' : 'whereNotIn'; $query = $query->{$method}($prop, (array)$filter['value']); } } return $query; } /** * @param \FluentCrm\Framework\Database\Orm\Builder|\FluentCrm\Framework\Database\Query\Builder $query * @param array $filters * @return \FluentCrm\Framework\Database\Query\Builder */ public function buildCustomFieldsFilterQuery($query, $filters) { $filters = array_reduce($filters, function ($carry, $filter) { $operator = $filter['operator']; if ($operator == 'not_in') { $carry['notIn'][] = $filter; } else if ($operator == '!=' || $operator == 'not_contains') { $carry['notEqualNorExist'][] = $filter; } else if ($operator == 'is_null' || $operator == 'not_null') { if ($operator == 'is_null') { $filter['method'] = 'whereDoesntHave'; } else { $filter['method'] = 'whereHas'; } $carry['exist_or_not'][] = $filter; } else { if ($operator == 'in') { $filter['operator'] = 'contains'; } else if ($operator == 'not_in') { $filter['operator'] = 'not_contains'; } $carry['regular'][] = $filter; } return $carry; }, []); if (array_key_exists('regular', $filters)) { foreach ($filters['regular'] as $filter) { $query->whereHas('custom_field_meta', function ($customFieldQuery) use ($filter) { $customFieldQuery->where('key', $filter['property']); $operator = self::parseCustomFieldsFilterOperator($filter); if (is_array($filter['value'])) { $customFieldQuery->where(function ($valueQuery) use ($operator, $filter) { $firstVal = array_shift($filter['value']); $valueQuery->where('value', $operator, '%' . $firstVal . '%'); foreach ($filter['value'] as $value) { $valueQuery->orWhere('value', $operator, '%' . $value . '%'); } }); } else { $customFieldQuery = self::applyGeneralFilterQuery($customFieldQuery, $filter, 'value'); } return $customFieldQuery; }); } } if (array_key_exists('notIn', $filters)) { foreach ($filters['notIn'] as $filter) { $filter['value'] = (array)$filter['value']; foreach ($filter['value'] as $value) { $query->whereDoesntHave('custom_field_meta', function ($customFieldQuery) use ($value, $filter) { $customFieldQuery->where('key', $filter['property']) ->where('value', 'LIKE', '%' . $value . '%'); }); } } } if (array_key_exists('notEqualNorExist', $filters)) { foreach ($filters['notEqualNorExist'] as $filter) { $value = (string)trim($filter['value']); $operator = $filter['operator']; if ($operator == 'not_contains') { $operator = 'LIKE'; $value = '%' . $value . '%'; } else { $operator = '='; } $query->whereDoesntHave('custom_field_meta', function ($customFieldQuery) use ($value, $filter, $operator) { $customFieldQuery->where('key', $filter['property']) ->where('value', $operator, $value); }); } } if (array_key_exists('exist_or_not', $filters)) { foreach ($filters['exist_or_not'] as $filter) { $query->{$filter['method']}('custom_field_meta', function ($customFieldQuery) use ($filter) { $customFieldQuery->where('key', $filter['property']); }); } } return $query; } public static function parseCustomFieldsFilterOperator($filter) { $operator = $filter['operator']; switch ($filter['operator']) { case 'contains': case 'in': $operator = 'LIKE'; break; case 'not_contains': case 'not_in': $operator = 'NOT LIKE'; break; } return $operator; } /** * @param \FluentCrm\Framework\Database\Orm\Builder|\FluentCrm\Framework\Database\Query\Builder $query * @param array $filters * @return \FluentCrm\Framework\Database\Orm\Builder|\FluentCrm\Framework\Database\Query\Builder */ public function buildActivitiesFilterQuery($query, $filters) { foreach ($filters as $filter) { if (empty($filter['value'])) { if (in_array($filter['property'], ['email_opened', 'email_link_clicked'])) { if ($filter['operator'] != 'never') { continue; } } else { continue; } } $originalValue = $filter['value']; $relation = 'campaignEmails'; $filter['where'] = [ 'prop' => 'status', 'value' => 'sent', 'field' => 'scheduled_at' ]; $filterProp = $filter['property']; if ($filterProp == 'email_opened' && $filter['operator'] == 'never') { $query->whereDoesntHave('campaignEmails', function ($q) { $q->where('is_open', 1); }); continue; } if ($filterProp == 'email_link_clicked' && $filter['operator'] == 'never') { $query->whereDoesntHave('campaignEmails', function ($q) { $q->whereNotNull('click_counter'); }); continue; } if ($filterProp == 'campaign_email_activity') { $campaignId = (int)$filter['value']; $operator = $filter['operator']; if ($operator == 'not_in') { $query->whereDoesntHave('campaignEmails', function ($q) use ($campaignId) { $q->where('campaign_id', $campaignId); }); } else { $query->whereHas('campaignEmails', function ($q) use ($campaignId, $operator) { $q->where('campaign_id', $campaignId); if ($operator == 'clicked') { $q->whereNotNull('click_counter'); } else if ($operator == 'not_clicked') { $q->whereNull('click_counter'); } else if ($operator == 'open') { $q->where('is_open', 1); } else if ($operator == 'no_open') { $q->where('is_open', '0'); } }); } continue; } else if ($filterProp == 'automation_activity') { $funnelId = (int)$filter['value']; $operator = $filter['operator']; if ($operator == 'not_in') { $query->whereDoesntHave('funnel_subscribers', function ($q) use ($funnelId) { $q->where('funnel_id', $funnelId); }); } else { $query->whereHas('funnel_subscribers', function ($q) use ($funnelId, $operator) { $q->where('funnel_id', $funnelId); $statusItems = ['completed', 'active', 'cancelled', 'waiting']; if (in_array($operator, $statusItems)) { $q->where('status', $operator); } }); } continue; } else if ($filterProp == 'email_sequence_activity') { $sequenceId = (int)$filter['value']; $operator = $filter['operator']; if ($operator == 'not_in') { $query->whereDoesntHave('sequence_trackers', function ($q) use ($sequenceId) { $q->where('campaign_id', $sequenceId); }); } else { $query->whereHas('sequence_trackers', function ($q) use ($sequenceId, $operator) { $q->where('campaign_id', $sequenceId); $statusItems = ['completed', 'active', 'cancelled']; if (in_array($operator, $statusItems)) { $q->where('status', $operator); } }); } continue; } else if ($filterProp == 'email_opened') { $relation = 'campaignEmails'; $filter['where'] = [ 'prop' => 'is_open', 'value' => 1, 'field' => 'updated_at' ]; } else if ($filterProp != 'email_sent') { $relation = 'urlMetrics'; $filter['where'] = [ 'prop' => 'type', 'value' => 'click', 'field' => 'updated_at' ]; } $filter = static::filterParser($filter); $query->whereHas($relation, function ($campaignEmailQuery) use ($filter, $relation) { $campaignEmailQuery->where($filter['where']['prop'], $filter['where']['value']); if ($filter['operator'] == 'BETWEEN') { $campaignEmailQuery->whereBetween($filter['where']['field'], $filter['value']); } else { $campaignEmailQuery->where($filter['where']['field'], $filter['operator'], $filter['value']); } }); $operator = $filter['operator']; if ($operator == '<' || $operator == 'LIKE') { if ($operator == 'LIKE') { $compareValue = $originalValue . ' 23:59:59'; } else { $compareValue = $filter['value'] . ' 23:59:59'; } $query->whereDoesntHave($relation, function ($campaignEmailQuery) use ($filter, $compareValue) { $campaignEmailQuery->where($filter['where']['prop'], $filter['where']['value']); $campaignEmailQuery->where($filter['where']['field'], '>', $compareValue); }); } } return $query; } public function lastActivityDate($activityName) { $validNames = ['email_sent', 'email_link_clicked', 'email_opened']; if (!in_array($activityName, $validNames)) { return false; } if ($activityName == 'email_sent') { $lastEmail = CampaignEmail::where('subscriber_id', $this->id) ->where('status', 'sent') ->orderBy('scheduled_at', 'DESC') ->first(); if ($lastEmail) { return $lastEmail->scheduled_at; } return false; } if ($activityName == 'email_opened') { $lastOpen = CampaignEmail::where('subscriber_id', $this->id) ->where('is_open', 1) ->orderBy('updated_at', 'DESC') ->first(); return $lastOpen ? $lastOpen->updated_at : false; } $lastActivity = CampaignUrlMetric::where('subscriber_id', $this->id) ->where('type', 'click') ->orderBy('updated_at', 'DESC') ->first(); if ($lastActivity) { return $lastActivity->updated_at; } return false; } public function user() { return $this->belongsTo(User::class, 'user_id', 'ID'); } public function getWpUser() { if ($this->user_id) { return get_user_by('ID', $this->user_id); } $user = get_user_by('email', $this->email); if ($user) { $this->user_id = $user->ID; $this->save(); // remove the same user_id for other subscribers self::where('user_id', $user->ID) ->where('id', '!=', $this->id) ->update([ 'user_id' => NULL ]); } return $user; } public function getWpUserId() { if ($this->user_id) { return $this->user_id; } $user = $this->getWpUser(); if ($user) { return $user->ID; } return null; } /** * Get the attributes that have been changed since last sync. * * @return array */ public function getDirty() { $dirty = []; foreach ($this->attributes as $key => $value) { if (!in_array($key, $this->fillable)) { continue; } if (!array_key_exists($key, $this->original)) { $dirty[$key] = $value; } elseif ($value !== $this->original[$key] && !$this->originalIsNumericallyEquivalent($key)) { $dirty[$key] = $value; } } return $dirty; } public function getSecureHash() { return fluentCrmGetContactSecureHash($this->id); } public function trackEvent($eventData, $isUnique = false) { $eventData['subscriber'] = $this; return FluentCrmApi('event_tracker')->track($eventData, $isUnique); } public function updateStatus($status) { if ($this->status == $status) { return $this; } $oldStatus = $this->status; $newStatus = $status; $this->status = $status; $this->save(); do_action('fluent_crm/subscriber_status_changed', $this, $oldStatus, $newStatus); do_action('fluentcrm_subscriber_status_to_' . $newStatus, $this, $oldStatus); return $this; } }