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 Models::Schedulable

Models::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 Models::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:



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

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)


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

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



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

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



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

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



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

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



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
319
# File 'app/models/visit.rb', line 294

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:



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

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:



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

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:



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

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:



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

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:



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

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:



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

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

Instance Method Details

#country_iso3Object



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

def country_iso3
  country_iso3166&.alpha3
end

#country_iso3166Object



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

def country_iso3166
  return if country.blank?

  ISO3166::Country.find_country_by_any_name(country)
end

#find_sourceObject

Smart source lookup



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

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



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

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



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

def keywords
  utm_term || search_keyword
end

#landing_page_addressableObject



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

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

#landing_page_paramsObject



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

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



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

def landing_page_path
  landing_page_addressable&.path || landing_page
end

#location_summaryObject



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

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

#opportunitiesActiveRecord::Relation<Opportunity>

Returns:

See Also:



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

has_many :opportunities

#ordersActiveRecord::Relation<Order>

Returns:

  • (ActiveRecord::Relation<Order>)

See Also:



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

has_many :orders

#partyParty

Returns:

See Also:



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

has_one :party, inverse_of: :visit

#quotesActiveRecord::Relation<Quote>

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



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

has_many :quotes

#remove_self_referrerObject



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

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:

See Also:



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

has_many :room_configurations

#set_sourceObject



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

def set_source
  self.source = find_source
end

#sourceSource

Returns:

See Also:



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

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

#state_codeObject



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

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

  c.find_subdivision_by_name(region)&.code
end

#update_associated_customer_sourceObject



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

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:



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

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

#visit_eventsActiveRecord::Relation<VisitEvent>

Returns:

See Also:



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

has_many :visit_events, inverse_of: :visit