Class: Visit

Inherits:
ApplicationRecord show all
Defined in:
app/models/visit.rb

Overview

== Schema Information

Table name: visits
Database name: primary

id :bigint not null, primary key
city :string
country :string
device_meta :jsonb not null
ip :string(39)
landing_page :text
latitude :decimal(10, 6)
legacy_browser :string
legacy_device_type :string
legacy_dnt :integer
legacy_gbraid :string
legacy_gclid :string
legacy_locale :string(5)
legacy_os :string
legacy_screen_height :integer
legacy_screen_width :integer
legacy_wbraid :string
longitude :decimal(10, 6)
marketing_meta :jsonb not null
postal_code :string
processed :boolean
referral_code :string
referrer :text
referring_domain :string
region :string
region_code :string(10)
search_keyword :string
started_at :datetime
user_agent :text
utm_campaign :string
utm_content :string
utm_medium :string
utm_source :string
utm_term :string
visitor_token :uuid
session_id :string
source_id :integer
user_id :integer
utm_id :string

Indexes

index_visits_on_marketing_meta (marketing_meta) USING gin
index_visits_on_referral_code (referral_code)
index_visits_on_referring_domain (referring_domain) USING gin
index_visits_on_source_id (source_id)
index_visits_on_started_at (started_at)
index_visits_on_user_id (user_id)

Foreign Keys

fk_rails_... (source_id => sources.id)
fk_rails_... (user_id => parties.id) ON DELETE => nullify

Constant Summary collapse

SQL_CONVERSIONS =

Sql conversions.

%w[orders quotes opportunities room_configurations].map { |t| "exists(select 1 from #{t} where #{t}.visit_id = visits.id)" }.join(' or ').freeze
%w[gclid gbraid wbraid].freeze

Constants included from Schedulable

Schedulable::SIMPLE_FORM_OPTIONS

Belongs to collapse

Has one collapse

Has many collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransortable_attributes, #to_relation

Methods included from Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Class Method Details

.by_source_id_with_descendantsActiveRecord::Relation<Visit>

A relation of Visits that are by source id with descendants. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Visit>)

See Also:



80
# File 'app/models/visit.rb', line 80

scope :by_source_id_with_descendants, ->(source_id) { where(source_id: Source.self_and_descendants_ids(source_id)) }

.last_marketing_value(key) ⇒ Object

Returns the value at marketing_meta[key] for the most-recent visit
in the current scope (highest id). Nil when the scope is empty.
Combines existence filter + ordering + jsonb_pick so callers don't
repeat the .with_marketing_key(...).order(id: :desc).jsonb_pick(...)
chain.

Examples:

Latest gclid across this customer's visits

customer.visits.last_marketing_value(:gclid)


153
154
155
156
# File 'app/models/visit.rb', line 153

def self.last_marketing_value(key)
  k = key.to_s
  with_marketing_key(k).reorder(id: :desc).jsonb_pick(column_name: :marketing_meta, key: k)
end

.populate_empty_sources(visits_query = nil) ⇒ Object



251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'app/models/visit.rb', line 251

def self.populate_empty_sources(visits_query = nil)
  counter = 0
  visits_query ||= Visit.all
  visits_query = visits_query.where(source_id: nil)
  max_counter = visits_query.size
  visits_query.find_in_batches do |visits|
    Visit.transaction do
      visits.each do |v|
        counter += 1
        new_source = v.find_source
        puts "[#{counter} / #{max_counter}] visit id #{v.id} -> #{new_source&.full_name || 'No Match'} [#{new_source&.id || '?'}]"
        v.update_column(:source_id, new_source.id) if new_source
      end
    end
  end
end

.populate_utm_idObject



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'app/models/visit.rb', line 162

def self.populate_utm_id
  visits = Visit.where(utm_id: nil).where("landing_page LIKE '%utm_id%'")
  total = visits.count
  puts "Populating utm_id for #{total} visits"
  visits.select(:id, :landing_page).find_in_batches.with_index do |group, batch|
    visit_ids_per_utm_id = {}
    offset = batch * 1000
    position = offset
    group.each_with_index do |v, _index|
      position += 1
      progress = ((position.to_f / total) * 100).round(2) if total > 0
      puts "[#{progress} % - #{position} / #{total}] #{v.id} -> #{v.landing_page}"
      utm_id = v.landing_page_params[:utm_id]
      if utm_id.present?
        visit_ids_per_utm_id[utm_id] ||= []
        visit_ids_per_utm_id[utm_id] << v.id
      end
    end
    # Perform a batch update
    visit_ids_per_utm_id.each do |utm_id, ids|
      puts "#{utm_id} : #{ids.inspect}"
      Visit.where(id: ids).update_all(utm_id: utm_id)
    end
  end
end

.ransackable_scopes(_auth_object = nil) ⇒ Object



158
159
160
# File 'app/models/visit.rb', line 158

def self.ransackable_scopes(_auth_object = nil)
  [:by_source_id_with_descendants]
end

.reconcile_with_parties(start_time:, end_time: nil) ⇒ Object

This method is under development



293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
# File 'app/models/visit.rb', line 293

def self.reconcile_with_parties(start_time:, end_time: nil)
  visits = Visit.where(user_id: nil).where(Visit[:started_at].gteq(start_time))
  visits = visits.where(Visit[:started_at].lteq(end_time)) if end_time
  visits.find_each do |visit|
    customer = Customer.find_by(visit_id: visit.id)
    # Find a customer created within 5 seconds of that range
    if customer.nil?
      puts 'Customer not found by visit id, attempting time range and ip lookup'
      time_range = (visit.started_at - 5.seconds)..(visit.started_at + 5.seconds)
      # Inspect the customer created in that time range with that IP, the last one will get the visit_id
      Customer.where(creation_method: 'web', created_at: time_range).order(Customer[:created_at].desc).find_each do |lookup_customer|
        if lookup_customer.versions.where(event: 'create').where(ip: visit.ip).exists?
          customer = lookup_customer
          puts "Customer #{customer.id} found based on ip creation event"
          break
        end
      end
    else
      puts "Found customer #{customer.id} via Visit id"
    end
    if customer
      visit.update_column(:user_id, customer.id)
      customer.update_column(:source_id, visit.source_id) if (customer.source.nil? || customer.source.unknown?) && visit.source_id
    end
  end
end

.with_any_marketing_keyActiveRecord::Relation<Visit>

A relation of Visits that are with any marketing key. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Visit>)

See Also:



134
135
136
137
138
# File 'app/models/visit.rb', line 134

scope :with_any_marketing_key, ->(*keys) {
  cleaned_keys = keys.flatten.map { |key| key.to_s.strip }.compact_blank

  cleaned_keys.empty? ? none : jsonb_where_exists_any(column_name: :marketing_meta, keys: cleaned_keys)
}

.with_conversionsActiveRecord::Relation<Visit>

A relation of Visits that are with conversions. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Visit>)

See Also:



77
# File 'app/models/visit.rb', line 77

scope :with_conversions, -> { select("*,(#{SQL_CONVERSIONS}) as has_conversions") }

.with_events_countActiveRecord::Relation<Visit>

A relation of Visits that are with events count. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Visit>)

See Also:



78
# File 'app/models/visit.rb', line 78

scope :with_events_count, -> { select('visits.*, (SELECT COUNT(*) FROM visit_events WHERE visit_events.visit_id = visits.id) as events_count') }

.with_marketing_keyActiveRecord::Relation<Visit>

A relation of Visits that are with marketing key. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Visit>)

See Also:



129
130
131
# File 'app/models/visit.rb', line 129

scope :with_marketing_key, ->(key) {
  jsonb_where_exists(column_name: :marketing_meta, key: key.to_s)
}

.with_marketing_metaActiveRecord::Relation<Visit>

A relation of Visits that are with marketing meta. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Visit>)

See Also:



141
142
143
# File 'app/models/visit.rb', line 141

scope :with_marketing_meta, ->(hash) {
  jsonb_where(column_name: :marketing_meta, operator: :contains, value: hash)
}

.with_order_conversionsActiveRecord::Relation<Visit>

A relation of Visits that are with order conversions. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Visit>)

See Also:



79
# File 'app/models/visit.rb', line 79

scope :with_order_conversions, -> { joins(:orders).where("orders.state = 'invoiced'") }

Instance Method Details

#country_iso3Object



204
205
206
# File 'app/models/visit.rb', line 204

def country_iso3
  country_iso3166&.alpha3
end

#country_iso3166Object



198
199
200
201
202
# File 'app/models/visit.rb', line 198

def country_iso3166
  return if country.blank?

  ISO3166::Country.find_country_by_any_name(country)
end

#find_sourceObject

Smart source lookup



235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'app/models/visit.rb', line 235

def find_source
  # Merge persisted UTM/referral attributes with landing page params so that
  # referral/campaign codes take precedence even if the landing URL is missing them
  params = landing_page_params.dup
  params[:referral_code] ||= referral_code if respond_to?(:referral_code)
  params[:utm_id] ||= utm_id if respond_to?(:utm_id)
  params[:utm_campaign] ||= utm_campaign if respond_to?(:utm_campaign)
  params[:utm_source] ||= utm_source if respond_to?(:utm_source)
  params[:utm_medium] ||= utm_medium if respond_to?(:utm_medium)
  params[:gclid]  ||= marketing_meta_gclid
  params[:gbraid] ||= marketing_meta_gbraid
  params[:wbraid] ||= marketing_meta_wbraid
  params[:oppref] ||= marketing_meta_oppref
  Source.find_from_params(params, referrer: referrer)
end

#geocodeObject



320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# File 'app/models/visit.rb', line 320

def geocode
  return if latitude && longitude

  location =
    begin
      Geocoder.search(ip).first
    rescue StandardError
      Rails.logger.error "Geocode error: #{e.class.name}: #{e.message}"
      nil
    end

  return unless location && location.country.present?

  data = {
    country: location.country,
    # country_code: location.try(:country_code).presence,
    region: location.try(:state).presence,
    city: location.try(:city).presence,
    postal_code: location.try(:postal_code).presence,
    latitude: location.try(:latitude).presence,
    longitude: location.try(:longitude).presence
  }
  update(data)
end

#keywordsObject



188
189
190
# File 'app/models/visit.rb', line 188

def keywords
  utm_term || search_keyword
end

#landing_page_addressableObject



212
213
214
215
216
217
218
# File 'app/models/visit.rb', line 212

def landing_page_addressable
  @landing_page_addressable ||= begin
    Addressable::URI.parse(landing_page)
  rescue StandardError
    nil
  end
end

#landing_page_paramsObject



224
225
226
227
228
229
230
231
232
# File 'app/models/visit.rb', line 224

def landing_page_params
  params = {}
  return params unless (query = landing_page_addressable&.query)

  params = Rack::Utils.parse_nested_query query
  params.with_indifferent_access
rescue StandardError
  {}
end

#landing_page_pathObject



220
221
222
# File 'app/models/visit.rb', line 220

def landing_page_path
  landing_page_addressable&.path || landing_page
end

#location_summaryObject



208
209
210
# File 'app/models/visit.rb', line 208

def location_summary
  [city, region, postal_code, country].compact.join(' ')
end

#opportunitiesActiveRecord::Relation<Opportunity>

Returns:

See Also:



74
# File 'app/models/visit.rb', line 74

has_many :opportunities

#ordersActiveRecord::Relation<Order>

Returns:

  • (ActiveRecord::Relation<Order>)

See Also:



72
# File 'app/models/visit.rb', line 72

has_many :orders

#partyParty

Returns:

See Also:



69
# File 'app/models/visit.rb', line 69

has_one :party, inverse_of: :visit

#quotesActiveRecord::Relation<Quote>

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



73
# File 'app/models/visit.rb', line 73

has_many :quotes

#remove_self_referrerObject



268
269
270
271
272
273
# File 'app/models/visit.rb', line 268

def remove_self_referrer
  return unless /\A(www\.)?warmlyyours\.com/.match?(referring_domain)

  self.referrer = nil
  self.referring_domain = nil
end

#room_configurationsActiveRecord::Relation<RoomConfiguration>

Returns:

  • (ActiveRecord::Relation<RoomConfiguration>)

See Also:



75
# File 'app/models/visit.rb', line 75

has_many :room_configurations

#set_sourceObject



275
276
277
# File 'app/models/visit.rb', line 275

def set_source
  self.source = find_source
end

#sourceSource

Returns:

See Also:



67
# File 'app/models/visit.rb', line 67

belongs_to :source, inverse_of: :visits, optional: true

#state_codeObject



192
193
194
195
196
# File 'app/models/visit.rb', line 192

def state_code
  return unless (c = country_iso3166) && region.present?

  c.find_subdivision_by_name(region)&.code
end

#update_associated_customer_sourceObject



279
280
281
282
283
284
285
286
287
288
289
290
# File 'app/models/visit.rb', line 279

def update_associated_customer_source
  return unless source && user

  current = user.source
  # Allow update when: no source, unknown source, or narrowing within the
  # same source tree (e.g. "Google" → "Google Ads").
  return unless current.nil? ||
                current.unknown_source? ||
                source.descendant_of?(current)

  user.update_attribute!(:source_id, source.id)
end

#userParty

Returns:

See Also:



66
# File 'app/models/visit.rb', line 66

belongs_to :user, class_name: 'Party', inverse_of: :visits, optional: true

#visit_eventsActiveRecord::Relation<VisitEvent>

Returns:

See Also:



71
# File 'app/models/visit.rb', line 71

has_many :visit_events, inverse_of: :visit