Class: Audience

Inherits:
ApplicationRecord show all
Includes:
Models::Auditable
Defined in:
app/models/audience.rb,
app/services/audience/party_backfill.rb

Overview

== Schema Information

Table name: audiences
Database name: primary

id :integer not null, primary key
add_all_emails :boolean
customer_search_params :jsonb
list_type :string
name :string
party_backfilled_at :datetime
created_at :datetime not null
updated_at :datetime not null
creator_id :integer
updater_id :integer
max_members :integer

Defined Under Namespace

Classes: PartyBackfill

Constant Summary collapse

LIST_TYPES =

Recognised list types — also the labels of the native audience_list_type
PG enum backing the column. Generic (no email_ prefix) because these lists
drive ad-platform audiences too, not just email campaigns.

%w[static dynamic customer].freeze
AD_PLATFORMS =

Ad platforms an audience can be flagged for. Google/Facebook/Pinterest have
Marketing::Audiences auto-sync adapters — Google + Facebook enable on creds
presence; Pinterest stays dormant until its audience_sync_enabled credential

  • ads:write scope land. ChatGPT/OpenAI has NO auto-sync adapter — its Ads
    API can't ingest audiences — so flagging it just records intent; fulfilment is
    the manual CSV export (AudienceOpenaiExportWorker + #openai_export). The daily
    sync skips any flagged platform whose adapter isn't enabled.
%w[google openai facebook pinterest].freeze
AD_PLATFORM_LABELS =

Human labels for the platform keys (form + filter). Single-sourced.

{
  'google' => 'Google Ads', 'openai' => 'ChatGPT Ads',
  'facebook' => 'Facebook/Meta', 'pinterest' => 'Pinterest'
}.freeze

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Has many collapse

Has and belongs to many collapse

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

#customer_search_paramsObject (readonly)

Validates customer search params, dynamic.

Validations (if => #dynamic? ):



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

validates :customer_search_params, presence: true, if: :dynamic?

#imported_audience_members_csvObject

Returns the value of attribute imported_audience_members_csv.



87
88
89
# File 'app/models/audience.rb', line 87

def imported_audience_members_csv
  @imported_audience_members_csv
end

#list_typeObject (readonly)

Validates name, list type.

Validations:



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

validates :name, :list_type, presence: true

#max_membersObject (readonly)

Cap for "top N" lists — generation ranks matching customers by lifetime
revenue before expanding their email addresses (see cap_results).

Validations:

  • Numericality ({ only_integer: true, greater_than: 0, allow_nil: true })


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

validates :max_members, numericality: { only_integer: true, greater_than: 0, allow_nil: true }

#nameObject (readonly)

Validates name, list type.

Validations:



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

validates :name, :list_type, presence: true

Class Method Details

.ad_syncedActiveRecord::Relation<Audience>

A relation of Audiences that are ad synced. Active Record Scope

Returns:

See Also:



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

scope :ad_synced, -> { where("jsonb_array_length(ad_platforms) > 0") }

.customersActiveRecord::Relation<Audience>

A relation of Audiences that are customers. Active Record Scope

Returns:

See Also:



64
# File 'app/models/audience.rb', line 64

scope :customers, -> { where(list_type: 'customer') }

.ensure_unique_name(name) ⇒ Object

Ensure unique name.

Parameters:

  • name (String)

    the name



97
98
99
100
101
102
# File 'app/models/audience.rb', line 97

def self.ensure_unique_name(name)
  unique_name = name.dup
  existing_list = where(name: unique_name).exists?
  unique_name << " #{Time.current.to_fs(:compact)}" if existing_list # we need to make the name unique
  unique_name
end

.for_ad_platformActiveRecord::Relation<Audience>

A relation of Audiences that are for ad platform. Active Record Scope

Returns:

See Also:



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

scope :for_ad_platform, ->(platform) { where("ad_platforms @> ?", [platform].to_json) }

Instance Method Details

#ad_platforms_recognizedObject

Reject ad_platforms values outside AD_PLATFORMS (a manipulated form submit).



90
91
92
93
# File 'app/models/audience.rb', line 90

def ad_platforms_recognized
  unknown = Array(ad_platforms) - AD_PLATFORMS
  errors.add(:ad_platforms, "not recognized: #{unknown.to_sentence}") if unknown.any?
end

#audience_member_countObject

Audience member count.



189
190
191
192
193
194
195
# File 'app/models/audience.rb', line 189

def audience_member_count
  if dynamic? && audience_members.empty?
    perform_search_query_count
  else
    audience_members.active.size
  end
end

#audience_member_counts_groupedObject

Audience member counts grouped.



198
199
200
201
202
203
204
205
206
207
208
# File 'app/models/audience.rb', line 198

def audience_member_counts_grouped
  if dynamic? && audience_members.empty?
    count = perform_search_query_count
    { 'active' => count }
  else
    {
      'active' => audience_members.active.count,
      'inactive' => audience_members.inactive.count
    }
  end
end

#audience_membersActiveRecord::Relation<AudienceMember>

No dependent: :destroy — audience_members are disposed individually so the ones
with delivery activity are archived (kept) rather than deleted. dispose runs
before the list row is removed; archived audience_members are detached so the
audience_id ON DELETE CASCADE FK doesn't reclaim them.

Returns:

See Also:



57
# File 'app/models/audience.rb', line 57

has_many :audience_members, inverse_of: :audience

#campaignsActiveRecord::Relation<Campaign>

Returns the associated campaigns.

Returns:

  • (ActiveRecord::Relation<Campaign>)

    the associated campaigns



59
# File 'app/models/audience.rb', line 59

has_and_belongs_to_many :campaigns

#email?Boolean

An email-campaign list (static or dynamic). customer? and dynamic? come
from the enum.

Returns:

  • (Boolean)


106
107
108
# File 'app/models/audience.rb', line 106

def email?
  static? || dynamic?
end

#generate_audience_membersObject

rubocop:disable Metrics/AbcSize -- pre-existing god-method; this change only
renames its list_type guard. Decompose separately.



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
# File 'app/models/audience.rb', line 241

def generate_audience_members
  return true unless dynamic?

  results = perform_search_query
  email_addresses = []
  if add_all_emails.to_b
    # reorder('') drops the search's default sort for the ids pluck — except a
    # capped list's revenue ranking (applied by perform_search_query) must survive.
    scoped = results.where.not(state: %w[closed bankrupt])
    scoped = scoped.reorder('') unless max_members
    customer_ids = scoped.ids
    email_addresses = ContactPoint.emails.under_customer_ids(customer_ids).distinct.pluck(:detail)
  else
    results.each do |search_result|
      if (email = search_result.customer_main_email || search_result.customer_first_contact_email)
        email_addresses << email
      end
    end
  end

  email_addresses.uniq! # remove any duplicates

  timestamp = Time.current

  logger.info "[generate_audience_members] AudienceMember list generation candidates: #{audience_members.size}"

  # Who's already subscribed, who's not
  already_subscribed = audience_members.pluck(:email_address) & email_addresses
  logger.info "[generate_audience_members] * already subscribed: #{already_subscribed.size}"

  # inactivate those not already_subscribed
  not_on_our_list = audience_members.where.not(email_address: already_subscribed)
  logger.info "[generate_audience_members] * Inactivating those not on our candidate list: #{not_on_our_list.size}"
  not_on_our_list.update_all(active: false, updated_at: timestamp)

  # Those already subscribed gets activated in case they where not active
  if already_subscribed.present?
    on_our_list_inactive = audience_members.where(email_address: already_subscribed).where.not(active: true)
    logger.info "[generate_audience_members] * Activating those from our candidate list which were inactive: #{on_our_list_inactive.size}"
    on_our_list_inactive.update_all(active: true, updated_at: timestamp)

    existing_active_unsubscribed = audience_members.where(active: true).joins(:email_preference).merge(EmailPreference.completely_unsubscribed)
    logger.info "[generate_audience_members] * De-activating #{existing_active_unsubscribed.size} audience_members who have completely unsubscribed"
    existing_active_unsubscribed.update_all(active: false, updated_at: timestamp)
  end

  # From our list who remains to create
  not_subscribed = email_addresses - already_subscribed

  # But we will trim it down first and remove everyone who is completele unsubscribed to emails
  completely_unsubscribed_emails = EmailPreference.completely_unsubscribed.where(email: not_subscribed).pluck(:email)
  logger.info "Out of #{not_subscribed.size} audience_members to add, removing #{completely_unsubscribed_emails.size} which are completely unsubcribed"

  not_subscribed -= completely_unsubscribed_emails

  if not_subscribed.present?
    logger.info "[generate_audience_members] * Need to now create #{not_subscribed.size} new audience_members"
    good_emails = not_subscribed.grep(ContactPoint::EMAIL_REGEXP) # filter out any bad email addresses, else we'll get a PG::SyntaxError
    new_audience_members = good_emails.map do |email|
      { audience_id: id,
                           email_address: email,
                           active: true }
    end
    result = AudienceMember.insert_all(new_audience_members) if new_audience_members.any?
    logger.info "[generate_audience_members] created #{result&.rows&.size || 0} audience_members"
  end
  logger.info '[generate_audience_members] * All done with generate_audience_members'

  true
end

#import_audience_members_from_csvObject

Import audience members from csv.



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'app/models/audience.rb', line 111

def import_audience_members_from_csv
  return if imported_audience_members_csv.blank?

  emails = []
  CSV.foreach(imported_audience_members_csv.path) do |row|
    emails << row[0] if row[0].present?
  end
  if add_all_emails == true
    emails.each do |email|
      parties = Party.joins(:contact_points).merge(ContactPoint.emails.where(detail: email))
      parties.each do |p|
        if p.is_a?(Contact) && p.customer.present?
          emails += p.customer.all_emails
        elsif p.is_a?(Customer)
          emails += p.all_emails
        end
      end
    end
  end
  emails.uniq.each do |email|
    audience_members << AudienceMember.new(email_address: email) unless audience_members.any? { |s| s.email_address == email }
  end
end

#name_and_typeObject

Label used by the audience_member-list picker on the campaign form. Includes
the creation date so duplicates with the same name are distinguishable.
AudienceMember count is intentionally excluded — for dynamic lists
it would fire perform_search_query_count (a full customer search) per
option on every render of the campaign form.



184
185
186
# File 'app/models/audience.rb', line 184

def name_and_type
  "#{name} [#{list_type}] · #{created_at.to_date}"
end

#perform_search_queryObject

Perform search query.



233
234
235
236
237
# File 'app/models/audience.rb', line 233

def perform_search_query
  assert_valid_customer_search_params!
  cs = CustomerSearch.create(query_params: customer_search_params)
  cap_results(cs.perform(nil, nil, nil, false, nil, true, true))
end

#perform_search_query_countObject

Perform search query count.



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'app/models/audience.rb', line 211

def perform_search_query_count
  assert_valid_customer_search_params!
  if add_all_emails.to_b
    # Count EMAIL ADDRESSES, not customers — generation expands each matching
    # customer to all of their emails, so the estimate (and the confirm gate)
    # must measure what will actually receive the send. Unsaved search: a
    # probe must not persist a searches row per call.
    cs = CustomerSearch.new(query_params: customer_search_params)
    results = cs.perform(nil, nil, nil, false, nil, false, true)
    results = cap_results(results)
    ContactPoint.emails
                .under_customer_ids(results.where.not(state: %w[closed bankrupt]).ids)
                .distinct
                .count(:detail)
  else
    cs = CustomerSearch.new(query_params: customer_search_params, selected_columns: [:id])
    count = cs.fast_count
    max_members ? [count, max_members].min : count
  end
end