Module: Marketing::Audiences

Defined in:
app/services/marketing/audiences.rb,
app/services/marketing/audiences/target.rb,
app/services/marketing/audiences/openai_export.rb,
app/services/marketing/audiences/google_adapter.rb,
app/services/marketing/audiences/facebook_adapter.rb,
app/services/marketing/audiences/pinterest_adapter.rb

Overview

Multi-platform audience reconcile (see Audiences in
audiences.rb) and its per-platform adapters.

Defined Under Namespace

Modules: OpenaiExport Classes: FacebookAdapter, GoogleAdapter, PinterestAdapter, Result, Target

Class Method Summary collapse

Class Method Details

.adapter_enabled?(adapter) ⇒ Boolean

A raising enabled? (e.g. a missing-config lookup) must skip only that
adapter, not abort the whole sync — so it's rescued here, not left to blow
up the select.

Parameters:

  • adapter (Object)

    an adapter instance (see the contract above)

Returns:

  • (Boolean)


102
103
104
105
106
107
# File 'app/services/marketing/audiences.rb', line 102

def adapter_enabled?(adapter)
  adapter.enabled?
rescue StandardError => e
  Rails.logger.error("[Marketing::Audiences] #{adapter.label} enabled? raised: #{e.class}: #{e.message}")
  false
end

.default_adaptersArray<Class>

itself via enabled?; Facebook/Pinterest stay dormant until their
per-platform audience_sync_enabled flag + creds are set (the daily run is
LIVE). ChatGPT/OpenAI is deliberately absent — its Ads API has no
audience-ingest endpoint, so it goes through the manual CSV export
(OpenaiExport), not an adapter.

Returns:

  • (Array<Class>)

    adapter classes, most-established first. Each gates



41
# File 'app/services/marketing/audiences.rb', line 41

def default_adapters = [GoogleAdapter, FacebookAdapter, PinterestAdapter]

.guarded(adapter, scope) { ... } ⇒ Result

Run one reconcile step, converting a raise into a recorded failure so the
daily pass is all-or-nothing per step, not per run.

Parameters:

  • adapter (Object)

    an adapter instance (see the contract above)

  • scope (String, Symbol)

    what was being reconciled (audience key or step)

Yields:

  • the reconcile step

Returns:



116
117
118
119
120
121
# File 'app/services/marketing/audiences.rb', line 116

def guarded(adapter, scope)
  Result.new(adapter: adapter.label, scope: scope, ok: true, detail: yield)
rescue StandardError => e
  Rails.logger.error("[Marketing::Audiences] #{adapter.label}/#{scope} failed: #{e.class}: #{e.message}")
  Result.new(adapter: adapter.label, scope: scope, ok: false, detail: e.message)
end

.member_emails(customer_scope) ⇒ Array<String>

Consent-gated marketable email addresses for an audience's members. The raw
detail strings — each adapter hashes per its platform's spec. .distinct:
party_ids spans the customer Party AND its Contact parties, so a shared
address would otherwise be returned (and hashed) more than once.

Parameters:

  • customer_scope (ActiveRecord::Relation)

Returns:

  • (Array<String>)


84
85
86
# File 'app/services/marketing/audiences.rb', line 84

def member_emails(customer_scope)
  ContactPoint.marketable_email.where(party_id: member_parties(customer_scope).select(:id)).distinct.pluck(:detail)
end

.member_parties(customer_scope) ⇒ ActiveRecord::Relation

An audience's parties: its customers AND their contacts (trade-account
people live on Contact rows, FK customer_id). Shared by adapters that build
their own payload.

Parameters:

  • customer_scope (ActiveRecord::Relation)

Returns:

  • (ActiveRecord::Relation)

    Party relation



72
73
74
75
# File 'app/services/marketing/audiences.rb', line 72

def member_parties(customer_scope)
  ids = customer_scope.select(:id)
  Party.where(id: ids).or(Party.where(customer_id: ids))
end

.member_phones(customer_scope) ⇒ Array<String>

Consent-gated (not on the DNC list) phone numbers for an audience's members.

Parameters:

  • customer_scope (ActiveRecord::Relation)

Returns:

  • (Array<String>)


92
93
94
# File 'app/services/marketing/audiences.rb', line 92

def member_phones(customer_scope)
  ContactPoint.marketable_phone.where(party_id: member_parties(customer_scope).select(:id)).distinct.pluck(:detail)
end

.sync(validate_only: true, audiences: Target.all, adapters: default_adapters) ⇒ Array<Result>

Reconcile every ad-synced audience into each platform it targets, then run
each stateful adapter's opt-out purge once. A failure in one audience/
platform step is logged and recorded but never aborts the rest.

Parameters:

  • validate_only (Boolean) (defaults to: true)

    true (default) dry-runs every adapter

  • audiences (Array<Audience>) (defaults to: Target.all)

    injectable for tests

  • adapters (Array<Class>) (defaults to: default_adapters)

    injectable for tests

Returns:



51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'app/services/marketing/audiences.rb', line 51

def sync(validate_only: true, audiences: Target.all, adapters: default_adapters)
  instances = adapters.map { |klass| klass.new(validate_only:) }.select { |a| adapter_enabled?(a) }.index_by(&:label)
  results = []
  audiences.each do |audience|
    audience.platforms.each do |platform|
      adapter = instances[platform]
      results << guarded(adapter, audience.key) { adapter.reconcile(audience) } if adapter
    end
  end
  instances.each_value do |adapter|
    results << guarded(adapter, :purge_suppressed) { adapter.purge_suppressed } if adapter.respond_to?(:purge_suppressed)
  end
  results
end