Class: Audience
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Audience
- 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 (noemail_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 itsaudience_sync_enabledcredentialads:writescope 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
-
#customer_search_params ⇒ Object
readonly
Validates customer search params, dynamic.
-
#imported_audience_members_csv ⇒ Object
Returns the value of attribute imported_audience_members_csv.
-
#list_type ⇒ Object
readonly
Validates name, list type.
-
#max_members ⇒ Object
readonly
Cap for "top N" lists — generation ranks matching customers by lifetime revenue before expanding their email addresses (see cap_results).
-
#name ⇒ Object
readonly
Validates name, list type.
Has many collapse
-
#audience_members ⇒ ActiveRecord::Relation<AudienceMember>
No
dependent: :destroy— audience_members are disposed individually so the ones with delivery activity are archived (kept) rather than deleted.
Has and belongs to many collapse
-
#campaigns ⇒ ActiveRecord::Relation<Campaign>
The associated campaigns.
Class Method Summary collapse
-
.ad_synced ⇒ ActiveRecord::Relation<Audience>
A relation of Audiences that are ad synced.
-
.customers ⇒ ActiveRecord::Relation<Audience>
A relation of Audiences that are customers.
-
.ensure_unique_name(name) ⇒ Object
Ensure unique name.
-
.for_ad_platform ⇒ ActiveRecord::Relation<Audience>
A relation of Audiences that are for ad platform.
Instance Method Summary collapse
-
#ad_platforms_recognized ⇒ Object
Reject ad_platforms values outside AD_PLATFORMS (a manipulated form submit).
-
#audience_member_count ⇒ Object
Audience member count.
-
#audience_member_counts_grouped ⇒ Object
Audience member counts grouped.
-
#email? ⇒ Boolean
An email-campaign list (static or dynamic).
-
#generate_audience_members ⇒ Object
rubocop:disable Metrics/AbcSize -- pre-existing god-method; this change only renames its list_type guard.
-
#import_audience_members_from_csv ⇒ Object
Import audience members from csv.
-
#name_and_type ⇒ Object
Label used by the audience_member-list picker on the campaign form.
-
#perform_search_query ⇒ Object
Perform search query.
-
#perform_search_query_count ⇒ Object
Perform search query count.
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
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#customer_search_params ⇒ Object (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_csv ⇒ Object
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_type ⇒ Object (readonly)
Validates name, list type.
Validations:
72 |
# File 'app/models/audience.rb', line 72 validates :name, :list_type, presence: true |
#max_members ⇒ Object (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 } |
#name ⇒ Object (readonly)
Validates name, list type.
Validations:
72 |
# File 'app/models/audience.rb', line 72 validates :name, :list_type, presence: true |
Class Method Details
.ad_synced ⇒ ActiveRecord::Relation<Audience>
A relation of Audiences that are ad synced. Active Record Scope
67 |
# File 'app/models/audience.rb', line 67 scope :ad_synced, -> { where("jsonb_array_length(ad_platforms) > 0") } |
.customers ⇒ ActiveRecord::Relation<Audience>
A relation of Audiences that are customers. Active Record Scope
64 |
# File 'app/models/audience.rb', line 64 scope :customers, -> { where(list_type: 'customer') } |
.ensure_unique_name(name) ⇒ Object
Ensure unique 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_platform ⇒ ActiveRecord::Relation<Audience>
A relation of Audiences that are for ad platform. Active Record Scope
69 |
# File 'app/models/audience.rb', line 69 scope :for_ad_platform, ->(platform) { where("ad_platforms @> ?", [platform].to_json) } |
Instance Method Details
#ad_platforms_recognized ⇒ Object
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_count ⇒ Object
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_grouped ⇒ Object
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_members ⇒ ActiveRecord::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.
57 |
# File 'app/models/audience.rb', line 57 has_many :audience_members, inverse_of: :audience |
#campaigns ⇒ ActiveRecord::Relation<Campaign>
Returns 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.
106 107 108 |
# File 'app/models/audience.rb', line 106 def email? static? || dynamic? end |
#generate_audience_members ⇒ Object
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 = 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: ) # 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: ) 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: ) 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_csv ⇒ Object
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_type ⇒ Object
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_query ⇒ Object
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_count ⇒ Object
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 |