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
CATEGORIES =

Reporting categories (Phase 1 attribution rework): segment marketing ROI
from outside-sales, platform/retailer channel volume, and referral
partners. Materialized on every node; descendants inherit their parent's.

%w[marketing outside_sales platform_retailer referral_partner].freeze
VISIBILITIES =

Visibilities.

%w[active hidden archived].freeze
EMAIL_CAMPAIGN_PREFIX =

Prefix of the full_name ltree path for human-created email-campaign
sources (e.g. "Campaigns > Email > June 2026 Webinar > Email 4: …").
These are the rows no *CampaignSyncWorker touches, so their outbound
links historically carried only ?referral_code=… (invisible to GA4,
which bucketed the clicks as Direct). See #default_campaign_utms.

'Campaigns > Email'
TRADE_SHOW_ROOT_NAME =

Root of the trade-show subtree. Everything beneath it is an event, and
events attribute differently from every other source: attendance is a
dated, one-off touch rather than a standing channel.

'Trade Show'
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 amazon_ads].freeze
DYNAMIC_CAMPAIGN_PARAMS =

Maps provider-specific dynamic URL parameters to synced campaign providers.

{
  msad_campaignid: 'microsoft_ads',
  oai_campaign_id: 'openai_ads'
}.freeze

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Schedulable

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

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#analytics_sourceObject (readonly)



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

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

#attribution_window_daysObject (readonly)



133
134
# File 'app/models/source.rb', line 133

validates :attribution_window_days, :pre_attribution_window_days,
numericality: { greater_than_or_equal_to: 0, only_integer: true }

#campaignObject (readonly)



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

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


176
177
# File 'app/models/source.rb', line 176

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

#categoryObject (readonly)



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

validates :category, inclusion: { in: CATEGORIES }, allow_nil: true

#descriptionObject (readonly)



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

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

#ends_onObject (readonly)

New shows cannot be created dateless — without a date there is no window to
measure attribution from, which is how the existing 340 became unjudgeable.
Only on create, so the undated legacy rows stay editable.

Validations (if => -> { new_record? && event? && !trade_show_root? } ):



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

validates :starts_on, :ends_on, presence: true, if: -> { new_record? && event? && !trade_show_root? }

#full_nameObject (readonly)



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

validates :full_name, length: { maximum: 500 }

#nameObject (readonly)



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

validates :name, presence: true

#pre_attribution_window_daysObject (readonly)



133
134
# File 'app/models/source.rb', line 133

validates :attribution_window_days, :pre_attribution_window_days,
numericality: { greater_than_or_equal_to: 0, only_integer: true }

#referral_codeObject (readonly)



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

validates :referral_code, uniqueness: { allow_blank: true }

#starts_onObject (readonly)

New shows cannot be created dateless — without a date there is no window to
measure attribution from, which is how the existing 340 became unjudgeable.
Only on create, so the undated legacy rows stay editable.

Validations (if => -> { new_record? && event? && !trade_show_root? } ):



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

validates :starts_on, :ends_on, presence: true, if: -> { new_record? && event? && !trade_show_root? }

Class Method Details

.all_options_for_select(exclude_source = nil) ⇒ Object



458
459
460
461
462
463
464
465
# File 'app/models/source.rb', line 458

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



254
255
256
# File 'app/models/source.rb', line 254

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

.bing_organicObject



258
259
260
# File 'app/models/source.rb', line 258

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

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



473
474
475
476
477
478
# File 'app/models/source.rb', line 473

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


480
481
482
# File 'app/models/source.rb', line 480

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

.chatgpt_adsObject



278
279
280
# File 'app/models/source.rb', line 278

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

.email_campaign_idObject



392
393
394
395
396
# File 'app/models/source.rb', line 392

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



250
251
252
# File 'app/models/source.rb', line 250

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:



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

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



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
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
# File 'app/models/source.rb', line 283

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 and OpenAI substitute campaign IDs into landing URLs at
  # delivery time. Resolve those IDs against nightly-synced campaign Sources
  # before the generic UTM fallback collapses a click onto a provider parent.
  s ||= resolve_dynamic_campaign(params)

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



180
181
182
# File 'app/models/source.rb', line 180

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

.generic_webObject



266
267
268
# File 'app/models/source.rb', line 266

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


238
239
240
# File 'app/models/source.rb', line 238

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

.google_organicObject



242
243
244
# File 'app/models/source.rb', line 242

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

.grouped_options_for_select(parent_id = nil) ⇒ Object



424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
# File 'app/models/source.rb', line 424

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:



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

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

.options_for_select_by_ref(ref_code) ⇒ Object



450
451
452
453
454
455
456
# File 'app/models/source.rb', line 450

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



388
389
390
# File 'app/models/source.rb', line 388

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

.parent_options_for_selectObject



484
485
486
# File 'app/models/source.rb', line 484

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

.pinterestObject



274
275
276
# File 'app/models/source.rb', line 274

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:



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

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

.select_optionsObject



410
411
412
# File 'app/models/source.rb', line 410

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

.select_suboptions(option) ⇒ Array<Integer>

Returns the ids of sources whose full name contains the given string.

Parameters:

  • option (String)

    substring matched against full_name

Returns:

  • (Array<Integer>)

    matching source ids



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

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:



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

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

.twitterObject



270
271
272
# File 'app/models/source.rb', line 270

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

.unknown_sourceObject



246
247
248
# File 'app/models/source.rb', line 246

def self.unknown_source
  Source.find(UNKNOWN_ID)
end

.utm_campaign_select_optionsObject



646
647
648
649
650
# File 'app/models/source.rb', line 646

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



658
659
660
661
662
# File 'app/models/source.rb', line 658

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



670
671
672
673
674
675
676
677
678
# File 'app/models/source.rb', line 670

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



630
631
632
633
634
635
636
637
638
# File 'app/models/source.rb', line 630

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:



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

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

.visible_options_for_select(account: nil) ⇒ Object



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

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:



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

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

.yahooObject



262
263
264
# File 'app/models/source.rb', line 262

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

Instance Method Details

#add_customer_to_campaign(customer) ⇒ Object



690
691
692
693
694
695
696
697
698
# File 'app/models/source.rb', line 690

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

#attributable_at?(time) ⇒ Boolean

Whether a record created at time may carry this source.

Undated events answer TRUE. 340 of them predate these columns and cannot be
judged; refusing them would fail every existing record that touches one.
Saying nothing about what we cannot know beats guessing — which is the
mistake this whole area is recovering from.

Parameters:

  • time (Time, Date, nil)

Returns:

  • (Boolean)


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

def attributable_at?(time)
  window = attribution_window
  return true if window.nil? || time.nil?

  window.cover?(time.to_date)
end

#attribution_windowRange<Date>?

When this event may legitimately be a record's source, spanning the whole
commercial arc of a show: lead-in → show → follow-up.

The lead-in matters as much as the follow-up. Builders register weeks
ahead, reps book appointments against a show they are about to attend, and
orders get written in the run-up — an order taken at the booth and an order
written the week before are the same sale. Refusing the lead-in would
conflate that ordinary commerce with the abuse this window exists to stop:
stamping a show onto business that predates it by a year.

#attribution_window_days is the follow-up side, where the calls happen.

Returns:

  • (Range<Date>, nil)

    nil when the event carries no dates



562
563
564
565
566
# File 'app/models/source.rb', line 562

def attribution_window
  return nil unless starts_on && ends_on

  (starts_on - pre_attribution_window_days.to_i.days)..(ends_on + attribution_window_days.to_i.days)
end

#call_logsActiveRecord::Relation<CallLog>

Returns:

  • (ActiveRecord::Relation<CallLog>)

See Also:



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

has_many :call_logs

#campaign_emailsActiveRecord::Relation<CampaignEmail>

Returns:

See Also:



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

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)


223
224
225
226
227
228
229
230
231
232
233
234
# File 'app/models/source.rb', line 223

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:



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

has_many :campaigns

#customersActiveRecord::Relation<Customer>

Returns:

See Also:



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

has_many :customers, dependent: :nullify

#default_campaign_utmsHash{Symbol=>String,nil}

Conventional UTM triplet for an email-campaign source, used both to
auto-fill the matching columns on save (#apply_default_campaign_utms)
and to tag outbound links (#tracked_url). Convention agreed
2026-06-30: utm_source=newsletter, utm_medium=email, and a
human-readable utm_campaign slug derived from the source name. The
referral_code rides along separately as the URL's referral_code
param (and is what find_from_params matches first). Empty for
non-email sources, which keep their existing (often sync-managed) UTMs.

Returns:

  • (Hash{Symbol=>String,nil})


594
595
596
597
598
599
600
601
602
# File 'app/models/source.rb', line 594

def default_campaign_utms
  return {} unless email_campaign?

  {
    utm_source: 'newsletter',
    utm_medium: 'email',
    utm_campaign: name.to_s.parameterize.presence
  }
end

#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)


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

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

  ltree_descendant_of?(other_source)
end

#email_campaign?Boolean

True for the Campaigns > Email > … subtree — the marketer-authored
email sources the ad-platform sync workers never populate.

Returns:

  • (Boolean)


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

def email_campaign?
  fn = effective_full_name
  fn == EMAIL_CAMPAIGN_PREFIX || fn.start_with?("#{EMAIL_CAMPAIGN_PREFIX} > ")
end

#event?Boolean

True for anything in the TRADE_SHOW_ROOT_NAME subtree.

Events attribute unlike every other source: attendance is a dated, one-off
touch rather than a standing channel, so "was this deal created near the
show?" is both answerable and worth asking.

Returns:

  • (Boolean)


539
540
541
542
# File 'app/models/source.rb', line 539

def event?
  fn = effective_full_name
  fn == TRADE_SHOW_ROOT_NAME || fn.start_with?("#{TRADE_SHOW_ROOT_NAME} > ")
end

#generate_ref_codeObject



724
725
726
727
728
729
730
731
# File 'app/models/source.rb', line 724

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:



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

has_many :invoices

#lead_countObject



704
705
706
# File 'app/models/source.rb', line 704

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

#linked_to_campaign?Boolean

Returns:

  • (Boolean)


519
520
521
# File 'app/models/source.rb', line 519

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

#name_for_selectObject



488
489
490
491
492
493
# File 'app/models/source.rb', line 488

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



733
734
735
# File 'app/models/source.rb', line 733

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

#ok_to_destroy?Boolean

Returns:

  • (Boolean)


700
701
702
# File 'app/models/source.rb', line 700

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

#opportunitiesActiveRecord::Relation<Opportunity>

Returns:

See Also:



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

has_many :opportunities

#opportunities_countObject



712
713
714
# File 'app/models/source.rb', line 712

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:



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

has_many :orders

#orders_countObject



708
709
710
# File 'app/models/source.rb', line 708

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

#postPost

Returns:

See Also:



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

has_one :post

#profitObject



720
721
722
# File 'app/models/source.rb', line 720

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

#referrer_regexp_select_optionsObject



686
687
688
# File 'app/models/source.rb', line 686

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

#revenueObject



716
717
718
# File 'app/models/source.rb', line 716

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



378
379
380
381
382
383
384
385
386
# File 'app/models/source.rb', line 378

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



507
508
509
# File 'app/models/source.rb', line 507

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

#self_and_descendants_events_sizeObject



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

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

#self_and_descendants_opportunity_sizeObject



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

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

#self_and_descendants_order_sizeObject



515
516
517
# File 'app/models/source.rb', line 515

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



495
496
497
# File 'app/models/source.rb', line 495

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



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

def self_and_descendants_visits_size
  Visit.by_source_id_with_descendants(id).count
end

#tracked_url(base_url, extra: {}) ⇒ String

Build a public www URL that carries both the internal referral_code
and GA-readable utm_* params, so a campaign click attributes to this
source in GA4 instead of collapsing into Direct. Stored UTM values win;
#default_campaign_utms fills any blanks for email-campaign sources.

Generalizes the URL-tagging that previously lived only inside
SourcesController#do_generate_qr so every campaign link builder (blog
post share links, the admin "sample link", future programmatic senders)
can produce a correctly tagged URL from one place.

Parameters:

  • base_url (String)

    destination page URL (absolute)

  • extra (Hash) (defaults to: {})

    extra query params, merged last

Returns:

  • (String)


617
618
619
620
621
622
623
624
625
626
627
628
# File 'app/models/source.rb', line 617

def tracked_url(base_url, extra: {})
  uri = Addressable::URI.parse(base_url.to_s)
  defaults = default_campaign_utms
  params = {
    'referral_code' => referral_code,
    'utm_source'    => Array(utm_source).first.presence   || defaults[:utm_source],
    'utm_medium'    => Array(utm_medium).first.presence   || defaults[:utm_medium],
    'utm_campaign'  => Array(utm_campaign).first.presence || defaults[:utm_campaign]
  }.compact
  uri.query_values = (uri.query_values || {}).merge(params).merge(extra.transform_keys(&:to_s))
  uri.to_s
end

#trade_show_root?Boolean

The subtree root itself, which is a category heading rather than a show.

Returns:

  • (Boolean)


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

def trade_show_root? = effective_full_name == TRADE_SHOW_ROOT_NAME

#unknown_source?Boolean

Returns:

  • (Boolean)


398
399
400
# File 'app/models/source.rb', line 398

def unknown_source?
  id == UNKNOWN_ID
end

#utm_campaign_select_optionsObject



652
653
654
655
656
# File 'app/models/source.rb', line 652

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

#utm_id_select_optionsObject



664
665
666
667
668
# File 'app/models/source.rb', line 664

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

#utm_medium_select_optionsObject



680
681
682
683
684
# File 'app/models/source.rb', line 680

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

#utm_sources_select_optionsObject



640
641
642
643
644
# File 'app/models/source.rb', line 640

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:



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

has_many :visits