Class: Source

Inherits:
ApplicationRecord show all
Includes:
Models::Auditable, Models::LiquidMethods, Models::LtreeLineage, Models::LtreePathBuilder, PgSearch::Model
Defined in:
app/models/source.rb

Overview

== Schema Information

Table name: sources
Database name: primary

id :integer not null, primary key
analytics_source :string(255)
campaign :string(255)
campaign_meta :jsonb not null
campaign_provider :string
customers_count :integer
description :string(255)
featured_until :datetime
full_name :string(500)
last_used_at :datetime
ltree_path_ids :ltree
ltree_path_slugs :ltree
name :string(255)
referral_code :string(20)
referrer_regexp :string is an Array
tracking_phone_number :string
utm_campaign :string is an Array
utm_medium :string is an Array
utm_source :string is an Array
visibility :enum default("active"), not null
created_at :datetime
updated_at :datetime
campaign_external_id :string
creator_id :integer
legacy_facebook_campaign_id :string
legacy_google_campaign_id :string
legacy_openai_ads_campaign_id :string
legacy_pinterest_campaign_id :string
parent_id :integer
updater_id :integer
utm_id :string is an Array

Indexes

index_sources_on_campaign_provider_and_external_id_unique (campaign_provider,campaign_external_id) UNIQUE WHERE ((campaign_provider IS NOT NULL) AND ((campaign_provider)::text <> ''::text) AND (campaign_external_id IS NOT NULL) AND ((campaign_external_id)::text <> ''::text))
index_sources_on_facebook_campaign_id_unique (legacy_facebook_campaign_id) UNIQUE WHERE ((legacy_facebook_campaign_id IS NOT NULL) AND ((legacy_facebook_campaign_id)::text <> ''::text))
index_sources_on_full_name (full_name)
index_sources_on_full_name_unique_active (full_name) UNIQUE WHERE (visibility = 'active'::source_visibility)
index_sources_on_google_campaign_id_unique (legacy_google_campaign_id) UNIQUE WHERE ((legacy_google_campaign_id IS NOT NULL) AND ((legacy_google_campaign_id)::text <> ''::text))
index_sources_on_last_used_at (last_used_at)
index_sources_on_ltree_path_ids (ltree_path_ids) USING gist
index_sources_on_ltree_path_slugs (ltree_path_slugs) USING gist
index_sources_on_name (name)
index_sources_on_openai_ads_campaign_id_unique (legacy_openai_ads_campaign_id) UNIQUE WHERE ((legacy_openai_ads_campaign_id IS NOT NULL) AND ((legacy_openai_ads_campaign_id)::text <> ''::text))
index_sources_on_parent_id (parent_id)
index_sources_on_pinterest_campaign_id_unique (legacy_pinterest_campaign_id) UNIQUE WHERE ((legacy_pinterest_campaign_id IS NOT NULL) AND ((legacy_pinterest_campaign_id)::text <> ''::text))
index_sources_on_referral_code (referral_code)

Defined Under Namespace

Classes: SetInvoiceSource

Constant Summary collapse

UNKNOWN_ID =

Unknown id.

851
VISIBILITIES =

Visibilities.

%w[active hidden archived].freeze
CAMPAIGN_PROVIDERS =

Known providers for the consolidated (campaign_provider, campaign_external_id)
tuple — each is wired to a *CampaignSyncWorker that populates a Source
row per active ad-platform campaign. The pre-consolidation per-provider
columns have been renamed to legacy_*_campaign_id
(20260519112434_rename_legacy_source_campaign_columns) and are no longer
read or written — a later migration drops them once the soak closes.

%w[google pinterest facebook openai_ads microsoft_ads].freeze

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Schedulable

Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Attributes included from Models::LtreePathBuilder

#skip_ltree_rebuild

Has one collapse

Has many collapse

Methods included from Models::LtreeLineage

#children

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #creator, #should_not_save_version, #stamp_record, #updater

Methods included from Models::LtreePathBuilder

#build_ltree_path_ids_value, #build_ltree_path_slugs_value, #build_slug_ltree_value, builds_ltree_path, #compute_ltree_ancestor_ids, #derive_cached_ancestor_ids, #ltree_descendant_ids, rebuild_all_ltree_paths!, rebuild_subtree_ltree_paths!, syncs_items_on_ltree_change?

Methods included from Models::LtreeLineage

acts_as_ltree_lineage, ancestors_ids, #ancestors_ids, define_ltree_scopes, define_ordered_ltree_methods, #descendant_of_path?, descendants_ids, #descendants_ids, #generate_full_name, #generate_full_name_array, #lineage, #lineage_array, #lineage_simple, #ltree_ancestor_of?, #ltree_descendant_of?, #ltree_slug, #ltree_slug_path, #parent, #path_includes?, #root, #root?, #root_id, root_ids, self_ancestors_and_descendants_ids, #self_ancestors_and_descendants_ids, self_and_ancestors_ids, #self_and_ancestors_ids, #self_and_children, self_and_descendants_ids, #self_and_descendants_ids, #self_and_siblings, #siblings

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#analytics_sourceObject (readonly)



113
# File 'app/models/source.rb', line 113

validates :name, :description, :campaign, :analytics_source, length: { maximum: 255 }

#campaignObject (readonly)



113
# File 'app/models/source.rb', line 113

validates :name, :description, :campaign, :analytics_source, length: { maximum: 255 }

#campaign_providerObject (readonly)

Whitelist the provider value and require the tuple to be populated
all-or-nothing so find_by_campaign can't silently miss rows that
happen to be half-set. Most sources carry no campaign mapping — the
provider is auto-populated only for synced ad-platform campaigns — so
blank must pass. allow_blank (not allow_nil) is deliberate: the
admin form's provider select submits "" for its blank option, which
allow_nil would reject ("is not included in the list"). The
normalizes line above also coerces that "" back to nil on write.

The if: has_attribute? guard keeps these validations from raising
NoMethodError: undefined method 'campaign_provider' when Source is
instantiated by a data migration that runs before the column was
added (20260516230748_add_campaign_provider_triple_to_sources) — e.g.
20260514130132_seed_chatgpt_ads_and_organic_sub_sources. Once the
column exists (production, and staging past that migration) the guard
is always true and both validations run exactly as before.

Validations (if => -> { has_attribute?(:campaign_provider) } ):

  • Inclusion ({ in: CAMPAIGN_PROVIDERS })
  • Allow_blank


145
146
# File 'app/models/source.rb', line 145

validates :campaign_provider, inclusion: { in: CAMPAIGN_PROVIDERS }, allow_blank: true,
if: -> { has_attribute?(:campaign_provider) }

#descriptionObject (readonly)



113
# File 'app/models/source.rb', line 113

validates :name, :description, :campaign, :analytics_source, length: { maximum: 255 }

#full_nameObject (readonly)



114
# File 'app/models/source.rb', line 114

validates :full_name, length: { maximum: 500 }

#nameObject (readonly)



110
# File 'app/models/source.rb', line 110

validates :name, presence: true

#referral_codeObject (readonly)



111
# File 'app/models/source.rb', line 111

validates :referral_code, uniqueness: { allow_blank: true }

Class Method Details

.all_options_for_select(exclude_source = nil) ⇒ Object



410
411
412
413
414
415
416
417
# File 'app/models/source.rb', line 410

def self.all_options_for_select(exclude_source = nil)
  sources = Source.all
  if exclude_source
    self_and_descendants_ids = exclude_source.self_and_descendants_ids
    sources = sources.where.not(id: exclude_source.self_and_descendants_ids) if self_and_descendants_ids.present?
  end
  sources.order(:full_name).map { |s| [s.name_for_select, s.id] }
end

.bing_cpcObject



206
207
208
# File 'app/models/source.rb', line 206

def self.bing_cpc
  Source.find_by(referral_code: 'BINGPPC')
end

.bing_organicObject



210
211
212
# File 'app/models/source.rb', line 210

def self.bing_organic
  Source.find_by(referral_code: 'BINGORG')
end

.campaign_options_for_select(customer, opportunity = nil, order = nil) ⇒ Object



425
426
427
428
429
430
# File 'app/models/source.rb', line 425

def self.campaign_options_for_select(customer, opportunity = nil, order = nil)
  options = customer.campaigns.active.map(&:source).map { |s| [s.full_name, s.id] }.sort_by { |s| s[0] }
  options << [opportunity.source.full_name, opportunity.source_id] if opportunity&.source&.campaigns&.any?
  options << [order.source.full_name, order.source_id] if order&.source&.campaigns&.any?
  options
end


432
433
434
# File 'app/models/source.rb', line 432

def self.campaign_sources_featured_for_customer(customer)
  Source.joins(:campaigns).merge(customer.campaigns.active).order(:name).pluck(:name, :id)
end

.chatgpt_adsObject



230
231
232
# File 'app/models/source.rb', line 230

def self.chatgpt_ads
  Source.find_by(referral_code: 'NIDZCM')
end

.email_campaign_idObject



348
349
350
351
352
# File 'app/models/source.rb', line 348

def self.email_campaign_id
  # Filter by visible + order by id so archived duplicates (cascaded from
  # name=NULL on a child source) can never shadow the canonical row.
  Source.visible.where(full_name: 'Campaigns > Email').order(:id).pick(:id)
end

.facebookObject



202
203
204
# File 'app/models/source.rb', line 202

def self.facebook
  Source.find_by(referral_code: 'H8SB1D')
end

.find_by_campaign(provider:, external_id:) ⇒ Source?

Convenience finder for the campaign-sync workers and downstream
attribution code (Google conversion reporter, lead processor).
Inputs are normalized the same way writes are (see normalizes line
above) so a caller passing whitespace-padded values doesn't miss an
existing row.

Parameters:

  • provider (String)
  • external_id (String)

    the ad-platform-side campaign ID

Returns:



162
163
164
165
166
167
# File 'app/models/source.rb', line 162

def self.find_by_campaign(provider:, external_id:)
  for_campaign(
    Heatwave::Normalizers.default(provider),
    Heatwave::Normalizers.default(external_id)
  ).first
end

.find_from_params(params, referrer: nil) ⇒ Object

Used to find source based on various web landing page urls etc



235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
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
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'app/models/source.rb', line 235

def self.find_from_params(params, referrer: nil)
  s = nil
  referrer = referrer.presence
  rc = params[:referral_code].presence || params[:rc].presence || params[:utm_id].presence || params[:utm_campaign].presence

  if rc.nil? && referrer
    parsed_ref = begin
      Addressable::URI.parse(referrer)
    rescue StandardError
      nil
    end
    # Parse the referral_code or rc in the query string of the referring page
    # This is useful for detecting the source from cached page entries
    # where the first hit to our server and session creation is by global.js
    rc = parsed_ref.query_values['referral_code'] || parsed_ref.query_values['rc'] || parsed_ref.query_values['utm_id'] if parsed_ref&.query_values
  end

  # Sanitize the rc, it has to be 6 alphanum
  rc = rc&.scan(/\w{6}/)&.first&.presence

  if rc
    # The referral code always takes precedence
    s = find_by(referral_code: rc)
  end

  # utm id are some of the most specific and tried first
  if s.nil? && params[:utm_id].present?
    s = where.overlap(utm_id: [params[:utm_id]]).first
    # Try a ref code match
    s ||= find_by(referral_code: params[:utm_id])
  end

  # utm campaign? this is the most specific therefore tried first
  if s.nil? && params[:utm_campaign].present?
    s = where.overlap(utm_campaign: [params[:utm_campaign]]).first
    # Try a ref code match
    s ||= find_by(referral_code: params[:utm_campaign])
  end

  # Microsoft Ads: the campaign id auto-substituted into the landing URL by
  # the account-level Final URL suffix (`msad_campaignid={CampaignId}`) maps
  # straight to the campaign Source the nightly sync created — matched on
  # `campaign_external_id`, so no per-campaign ref code needs to live on the
  # ad URL (mirrors Google's `gad_campaignid`). Must run BEFORE the generic
  # utm_source/medium fallback below, which would otherwise lump the visit
  # onto the parent "Bing" paid source.
  s = find_by_campaign(provider: 'microsoft_ads', external_id: params[:msad_campaignid]) if s.nil? && params[:msad_campaignid].present?

  # Lookup by utm_source & medium
  if s.nil? && params[:utm_source].present?
    candidates = where.overlap(utm_source: [params[:utm_source]])
    s = candidates.where.overlap(utm_medium: [params[:utm_medium]]).first if params[:utm_medium].present?
    # If no match on utm medium we take the first match on source
    s ||= candidates.first
  end

  # referrer, we're going to use ruby's regular expression, so first detect which
  # source to evaluate then pass them through the regexp evaluator
  if s.nil? && referrer.present?
    s = where('referrer_regexp IS NOT NULL AND cardinality(referrer_regexp) > 0').find do |source|
      source.safe_referrer_regexp.any? { |regexp| regexp.match?(referrer) }
    end
  end

  # Now fallback to legacy methods

  # _vsrefdom or other indicator or a
  s ||= google_ads if params['_vsrefdom'] == 'googleppc' ||
                      params['utm_source'] == 'googleppc' ||
                      params['gclid'].present? ||
                      referrer =~ %r{\Ahttp(s)?://www\.googleadservices\.}
  # OpenAI Ads destination URLs carry only `oppref` (+ `olref`) — no utm
  # params, no Referer. When oppref is the only signal, fall back to the
  # ChatGPT Ads parent. Per-campaign children (5889, 6222, …) still win
  # via the utm_campaign overlap above when the destination URL is
  # templated with `utm_campaign=<campaign_referral_code>`.
  s ||= chatgpt_ads if params[:oppref].present? || params['oppref'].present?
  # Google free product listing click identifier. Routed here in the
  # legacy section (rather than above utm_source / referrer matching)
  # so a stronger platform signal — utm_source=chatgpt.com + srsltid,
  # or referrer=chatgpt.com + srsltid — doesn't get clobbered by the
  # GOOGPL match. ChatGPT's web-search responses sometimes cite our
  # pages using URLs Google had previously tagged with `srsltid`, so
  # the click came from ChatGPT but carried a stale Google query
  # param along for the ride.
  s ||= find_by(referral_code: 'GOOGPL') if params[:srsltid].present?
  s ||= google_organic if %r{\Ahttp(s)?://www\.google\.}.match?(referrer)
  s ||= bing_cpc if params['utm_source'] == 'bing' &&
                    params['utm_medium'] == 'cpc'
  s ||= bing_organic if /bing\./.match?(referrer)
  s ||= yahoo if /yahoo\./.match?(referrer)
  s ||= twitter if %r{/t\.co/}.match?(referrer)
  s ||= facebook if /facebook\./.match?(referrer)
  s ||= pinterest if /pinterest\./.match?(referrer)
  s ||= generic_web if referrer.present? && (referrer =~ /warmlyyours\.com/).nil?
  s
end

.for_campaignActiveRecord::Relation<Source>

A relation of Sources that are for campaign. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Source>)

See Also:



149
150
151
# File 'app/models/source.rb', line 149

scope :for_campaign, ->(provider, external_id) {
  where(campaign_provider: provider, campaign_external_id: external_id)
}

.generic_webObject



218
219
220
# File 'app/models/source.rb', line 218

def self.generic_web
  Source.find_by(referral_code: 'WEBSE')
end


190
191
192
# File 'app/models/source.rb', line 190

def self.google_ads
  Source.find_by(referral_code: '9S6XPT')
end

.google_organicObject



194
195
196
# File 'app/models/source.rb', line 194

def self.google_organic
  Source.find_by(referral_code: '21TKYJ')
end

.grouped_options_for_select(parent_id = nil) ⇒ Object



376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
# File 'app/models/source.rb', line 376

def self.grouped_options_for_select(parent_id = nil)
  sources_grouped = {}
  source = begin
    Source.find(parent_id)
  rescue StandardError
    nil
  end
  if source.nil?
    sources_grouped['Categories'] = Source.visible.roots_sorted.map { |s| [s.name, s.id] }
  else
    all_sources = []
    source_children = source.children.visible.sort_by { |s| s.full_name.to_s }
    if source_children.empty?
      # Basically do not render the tree down any further
      source.name = source.full_name
      all_sources << source
    else
      source.name = "#{source.full_name} (#{source_children.length} more choices below)"
      all_sources << source
      all_sources.concat(source_children.each { |s| s.name = " > #{s.name} [#{s.referral_code}]" })
    end
    sources_grouped["Sources for #{source.name}"] = all_sources.map { |s| [s.name, s.id] }
  end
  sources_grouped
end

.not_archivedActiveRecord::Relation<Source>

A relation of Sources that are not archived. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Source>)

See Also:



96
# File 'app/models/source.rb', line 96

scope :not_archived, -> { where.not(visibility: :archived) }

.options_for_select_by_ref(ref_code) ⇒ Object



402
403
404
405
406
407
408
# File 'app/models/source.rb', line 402

def self.options_for_select_by_ref(ref_code)
  source_name = "%#{ref_code}%"
  return Source.grouped_options_for_select(nil) if ref_code.blank?

  all_sources = Source.where('full_name ILIKE ?', source_name)
  [["Search results for #{ref_code}", all_sources.map { |s| [s.full_name, s.id] }]]
end

.outside_sales_campaign_idObject



344
345
346
# File 'app/models/source.rb', line 344

def self.outside_sales_campaign_id
  Source.visible.where(full_name: 'Campaigns > Outside Sales').order(:id).pick(:id)
end

.parent_options_for_selectObject



436
437
438
# File 'app/models/source.rb', line 436

def self.parent_options_for_select
  Source.roots.order(:full_name).map { |s| [s.full_name, s.id] }
end

.pinterestObject



226
227
228
# File 'app/models/source.rb', line 226

def self.pinterest
  Source.find_by(referral_code: 'Y88HHI')
end

.roots_sortedActiveRecord::Relation<Source>

A relation of Sources that are roots sorted. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Source>)

See Also:



97
# File 'app/models/source.rb', line 97

scope :roots_sorted, -> { where(Source[:parent_id].eq(nil)).sorted }

.select_optionsObject



366
367
368
# File 'app/models/source.rb', line 366

def self.select_options
  Source.visible.roots_sorted.map { |s| [s.name, s.name] }
end

.select_suboptions(option) ⇒ Object



370
371
372
373
374
# File 'app/models/source.rb', line 370

def self.select_suboptions(option)
  source_option = "%#{option}%"
  sources_suboptions = Source.where('full_name ILIKE ?', source_option)
  sources_suboptions.map(&:id)
end

.sortedActiveRecord::Relation<Source>

A relation of Sources that are sorted. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Source>)

See Also:



94
# File 'app/models/source.rb', line 94

scope :sorted, -> { order(:name) }

.twitterObject



222
223
224
# File 'app/models/source.rb', line 222

def self.twitter
  Source.find_by(referral_code: 'W73BKY')
end

.unknown_sourceObject



198
199
200
# File 'app/models/source.rb', line 198

def self.unknown_source
  Source.find(UNKNOWN_ID)
end

.utm_campaign_select_optionsObject



491
492
493
494
495
# File 'app/models/source.rb', line 491

def self.utm_campaign_select_options
  Rails.cache.fetch(%i[sources utm_campaign select], expires_in: 24.hours) do
    Visit.where.not(utm_campaign: nil).distinct.pluck(:utm_campaign).filter_map { |o| Addressable::URI.unescape(o) }.uniq.sort
  end || []
end

.utm_id_select_optionsObject



503
504
505
506
507
# File 'app/models/source.rb', line 503

def self.utm_id_select_options
  Rails.cache.fetch(%i[sources utm_id select], expires_in: 24.hours) do
    Visit.where.not(utm_id: nil).distinct.pluck(:utm_id).filter_map { |o| Addressable::URI.unescape(o) }.uniq.sort
  end || []
end

.utm_medium_select_optionsObject



515
516
517
518
519
520
521
522
523
# File 'app/models/source.rb', line 515

def self.utm_medium_select_options
  Rails.cache.fetch(%i[sources utm_sources], expires_in: 24.hours) do
    Visit.where.not(utm_medium: nil).order(:utm_medium).pluck('distinct utm_medium').filter_map(&:presence).uniq.filter_map do |o|
      Addressable::URI.unescape(o)
    rescue StandardError
      nil
    end
  end || []
end

.utm_sources_select_optionsObject



475
476
477
478
479
480
481
482
483
# File 'app/models/source.rb', line 475

def self.utm_sources_select_options
  Rails.cache.fetch(%i[sources utm_sources], expires_in: 24.hours) do
    Visit.where.not(utm_source: nil).order(:utm_source).pluck('distinct utm_source').filter_map(&:presence).uniq.filter_map do |o|
      Addressable::URI.unescape(o)
    rescue StandardError
      nil
    end
  end || []
end

.visibleActiveRecord::Relation<Source>

A relation of Sources that are visible. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Source>)

See Also:



95
# File 'app/models/source.rb', line 95

scope :visible, -> { where(visibility: :active) }

.visible_options_for_select(account: nil) ⇒ Object



419
420
421
422
423
# File 'app/models/source.rb', line 419

def self.visible_options_for_select(account: nil)
  full_source_access =  && (.has_role?('marketing_rep') || .can?(:manage, Source))
  sources = full_source_access ?  Source.all : Source.visible
  sources.order(:full_name).map { |s| [s.name_for_select, s.id] }
end

.with_children_countActiveRecord::Relation<Source>

A relation of Sources that are with children count. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Source>)

See Also:



98
# File 'app/models/source.rb', line 98

scope :with_children_count, -> { select('sources.*,(select count(sc.id) from sources sc where sc.parent_id = sources.id) as children_count') }

.yahooObject



214
215
216
# File 'app/models/source.rb', line 214

def self.yahoo
  Source.find_by(referral_code: 'YAHOO')
end

Instance Method Details

#add_customer_to_campaign(customer) ⇒ Object



535
536
537
538
539
540
541
542
543
# File 'app/models/source.rb', line 535

def add_customer_to_campaign(customer)
  return if customer.guest?
  return unless customer.contactable?

  campaign = campaigns.first || campaign_emails.first.try(:campaign)
  return if campaign.nil?

  campaign.add_customer(customer)
end

#call_logsActiveRecord::Relation<CallLog>

Returns:

  • (ActiveRecord::Relation<CallLog>)

See Also:



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

has_many :call_logs

#campaign_emailsActiveRecord::Relation<CampaignEmail>

Returns:

See Also:



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

has_many :campaign_emails

#campaign_urlString?

Deep link to this campaign in the ad platform's manager UI — present
only for synced ad-platform campaign sources (those carrying the
(campaign_provider, campaign_external_id) tuple). Returns nil for
non-campaign sources and for providers without a known URL scheme.

Returns:

  • (String, nil)


175
176
177
178
179
180
181
182
183
184
185
186
# File 'app/models/source.rb', line 175

def campaign_url
  return nil if campaign_provider.blank? || campaign_external_id.blank?

  case campaign_provider
  when 'facebook'
     = Heatwave::Configuration.fetch(:facebook, :ad_account_id)
    return nil if .blank?

    query = URI.encode_www_form(act: .to_s, selected_campaign_ids: campaign_external_id.to_s)
    "https://adsmanager.facebook.com/adsmanager/manage/campaigns?#{query}"
  end
end

#campaignsActiveRecord::Relation<Campaign>

Returns:

See Also:



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

has_many :campaigns

#customersActiveRecord::Relation<Customer>

Returns:

See Also:



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

has_many :customers, dependent: :nullify

#descendant_of?(other_source) ⇒ Boolean

Returns true if this source is a more specific child of the given source.
Used for "narrowing" attribution: Google (4003) → Google Ads (1155).

Returns:

  • (Boolean)


360
361
362
363
364
# File 'app/models/source.rb', line 360

def descendant_of?(other_source)
  return false if other_source.nil?

  ltree_descendant_of?(other_source)
end

#generate_ref_codeObject



569
570
571
572
573
574
575
576
# File 'app/models/source.rb', line 569

def generate_ref_code
  begin
    # I avoid 0 and O since they could lead to confusion when printing/reciting
    alphanumerics = [('1'..'9'), ('A'..'N'), ('P'..'Z')].map(&:to_a).flatten
    new_ref_code = (0...6).map { alphanumerics[Kernel.rand(alphanumerics.size)] }.join
  end until Source.where(referral_code: new_ref_code).empty?
  new_ref_code
end

#invoicesActiveRecord::Relation<Invoice>

Returns:

  • (ActiveRecord::Relation<Invoice>)

See Also:



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

has_many :invoices

#lead_countObject



549
550
551
# File 'app/models/source.rb', line 549

def lead_count
  Customer.non_guests.where(source_id: self_and_descendants_ids).count
end

#linked_to_campaign?Boolean

Returns:

  • (Boolean)


471
472
473
# File 'app/models/source.rb', line 471

def linked_to_campaign?
  campaigns.present? || campaign_emails.present?
end

#name_for_selectObject



440
441
442
443
444
445
# File 'app/models/source.rb', line 440

def name_for_select
  base = self[:full_name].presence || self[:name].presence || "Source #{id}"
  n = +base.to_s
  n << " [#{referral_code}]" if referral_code.present? && n.exclude?(referral_code.to_s)
  n
end

#normalize_formatObject



578
579
580
# File 'app/models/source.rb', line 578

def normalize_format
  self.tracking_phone_number = PhoneNumber.parse_and_format(tracking_phone_number)
end

#ok_to_destroy?Boolean

Returns:

  • (Boolean)


545
546
547
# File 'app/models/source.rb', line 545

def ok_to_destroy?
  customers.empty? && children.each(&:ok_to_destroy?)
end

#opportunitiesActiveRecord::Relation<Opportunity>

Returns:

See Also:



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

has_many :opportunities

#opportunities_countObject



557
558
559
# File 'app/models/source.rb', line 557

def opportunities_count
  Opportunity.joins(:customer).where(source_id: self_and_descendants_ids).where("parties.state <> 'guest'").count
end

#ordersActiveRecord::Relation<Order>

Returns:

  • (ActiveRecord::Relation<Order>)

See Also:



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

has_many :orders

#orders_countObject



553
554
555
# File 'app/models/source.rb', line 553

def orders_count
  Order.where(source_id: self_and_descendants_ids).count
end

#postPost

Returns:

See Also:



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

has_one :post

#profitObject



565
566
567
# File 'app/models/source.rb', line 565

def profit
  Invoice.where(source_id: self_and_descendants_ids).sum(:profit_consolidated)
end

#referrer_regexp_select_optionsObject



531
532
533
# File 'app/models/source.rb', line 531

def referrer_regexp_select_options
  (referrer_regexp || []).filter_map(&:presence).uniq
end

#revenueObject



561
562
563
# File 'app/models/source.rb', line 561

def revenue
  Invoice.where(source_id: self_and_descendants_ids).sum(:revenue_consolidated)
end

#safe_referrer_regexpObject

Makes sure you get a clean array of Regexp object for referrer_regexp



334
335
336
337
338
339
340
341
342
# File 'app/models/source.rb', line 334

def safe_referrer_regexp
  return [] if referrer_regexp.blank?

  referrer_regexp.filter_map do |regexp|
    Regexp.new(regexp)
  rescue StandardError
    nil
  end
end

#self_and_descendants_customer_sizeObject



459
460
461
# File 'app/models/source.rb', line 459

def self_and_descendants_customer_size
  Customer.non_guests.where(source_id: self_and_descendants_ids).count
end

#self_and_descendants_events_sizeObject



455
456
457
# File 'app/models/source.rb', line 455

def self_and_descendants_events_size
  Visit.where(source_id: self_and_descendants_ids).joins(:visit_events).count
end

#self_and_descendants_opportunity_sizeObject



463
464
465
# File 'app/models/source.rb', line 463

def self_and_descendants_opportunity_size
  Opportunity.where(source_id: self_and_descendants_ids).count
end

#self_and_descendants_order_sizeObject



467
468
469
# File 'app/models/source.rb', line 467

def self_and_descendants_order_size
  Order.where(source_id: self_and_descendants_ids, state: 'invoiced', order_type: 'SO').count
end

#self_and_descendants_referral_codeObject



447
448
449
# File 'app/models/source.rb', line 447

def self_and_descendants_referral_code
  @self_and_descendants_referral_code ||= self_and_descendants.where.not(referral_code: nil).pluck(:referral_code)
end

#self_and_descendants_visits_sizeObject



451
452
453
# File 'app/models/source.rb', line 451

def self_and_descendants_visits_size
  Visit.by_source_id_with_descendants(id).count
end

#unknown_source?Boolean

Returns:

  • (Boolean)


354
355
356
# File 'app/models/source.rb', line 354

def unknown_source?
  id == UNKNOWN_ID
end

#utm_campaign_select_optionsObject



497
498
499
500
501
# File 'app/models/source.rb', line 497

def utm_campaign_select_options
  res = self.class.utm_campaign_select_options
  res += utm_campaign || []
  res.compact.uniq.sort
end

#utm_id_select_optionsObject



509
510
511
512
513
# File 'app/models/source.rb', line 509

def utm_id_select_options
  res = self.class.utm_id_select_options
  res += utm_id || []
  res.compact.uniq.sort
end

#utm_medium_select_optionsObject



525
526
527
528
529
# File 'app/models/source.rb', line 525

def utm_medium_select_options
  res = self.class.utm_medium_select_options
  res += utm_medium || []
  res.compact.uniq.sort
end

#utm_sources_select_optionsObject



485
486
487
488
489
# File 'app/models/source.rb', line 485

def utm_sources_select_options
  res = self.class.utm_sources_select_options
  res += utm_source || []
  res.compact.uniq.sort
end

#visitsActiveRecord::Relation<Visit>

Returns:

  • (ActiveRecord::Relation<Visit>)

See Also:



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

has_many :visits