Class: Campaign

Inherits:
ApplicationRecord show all
Includes:
Models::Auditable, Models::LiquidMethods
Defined in:
app/models/campaign.rb

Overview

== Schema Information

Table name: campaigns
Database name: primary

id :integer not null, primary key
active_since :datetime
auto_assign_source :boolean
auto_assign_until :date
auto_enroll :boolean default(FALSE)
campaign_type :string
category :string
description :text
end_date :date
exclude_from_monthly_report :boolean default(FALSE), not null
name :string(255) not null
start_date :date
state :string
created_at :datetime
updated_at :datetime
creator_id :integer
customer_filter_id :integer
source_id :integer
updater_id :integer

Indexes

idx_campaign_type (campaign_type)
idx_campaigns_state (state)
index_campaigns_on_customer_filter_id (customer_filter_id)
index_campaigns_on_source_id (source_id)

Foreign Keys

fk_rails_... (customer_filter_id => customer_filters.id)

Defined Under Namespace

Classes: AddCustomerResult, AssignCustomersToAudience, AssignDripCampaigns, DripContext

Constant Summary collapse

CATEGORIES =

Categories.

%w[announcements events newsletters promotions webinars].freeze
CAMPAIGN_TYPES =

Recognised campaign types.
event campaigns represent a trade show. They point at an EXISTING source
(the show, under Trade Show > …) rather than generating one under
Campaigns > …, which before_create :generate_source already skips when
source_id is set. They exist so attendance is recordable as a touch —
see Marketing::RecordEventAttendance — and so shows appear in the influence
report, which keys on campaigns.

%w[email_marketing outside_sales event].freeze

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Has and belongs to many collapse

Has many collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

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

#auto_assign_window_daysObject (readonly)

Validates auto assign window days.

Validations:



91
# File 'app/models/campaign.rb', line 91

validates :auto_assign_window_days, numericality: { only_integer: true, greater_than: 0 }

#campaign_typeObject (readonly)

Validates name, campaign type.

Validations:



89
# File 'app/models/campaign.rb', line 89

validates :name, :campaign_type, presence: true

#categoryObject (readonly)

Validates category.

Validations (if => proc { |c| c.campaign_type == 'email_marketing' } ):

Validations:



93
# File 'app/models/campaign.rb', line 93

validates :category, presence: true, if: proc { |c| c.campaign_type == 'email_marketing' }

#nameObject (readonly)

Validates name, campaign type.

Validations:



89
# File 'app/models/campaign.rb', line 89

validates :name, :campaign_type, presence: true

Class Method Details

.activeActiveRecord::Relation<Campaign>

A relation of Campaigns that are active. Active Record Scope

Returns:

See Also:



103
# File 'app/models/campaign.rb', line 103

scope :active, -> { where(state: 'active') }

.active_options_for_selectArray<Array>

Returns [label, id] pairs for select dropdowns.

Returns:

  • (Array<Array>)

    [label, id] pairs for select dropdowns



216
217
218
# File 'app/models/campaign.rb', line 216

def self.active_options_for_select
  where(state: 'active').order(:name).map { |c| [c.name_and_type, c.id] }
end

.merge_audiences(campaign_ids: nil) ⇒ Object

Merge audiences.

Parameters:

  • campaign_ids (Object) (defaults to: nil)

    the campaign ids



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
# File 'app/models/campaign.rb', line 161

def self.merge_audiences(campaign_ids: nil)
  unshared_audiences = Audience
                              .customers
                              .where(%{
                    (select count(*)
                     from audiences_campaigns csl
                     where csl.audience_id = audiences.id) = 1})
  campaigns = Campaign.joins(:audiences)
                      .merge(unshared_audiences)
                      .where(%{
                        (select count(*) from audiences_campaigns csl
                                inner join audiences sl on sl.id = csl.audience_id
                                where sl.list_type = 'customer'
                                  and csl.campaign_id = campaigns.id) > 1 })
  campaigns = campaigns.where(id: campaign_ids) if campaign_ids.present?
  Campaign.transaction do
    merged_audience_members = []
    audience_member_ids_to_delete = []
    audience_ids_to_delete = []
    campaigns.distinct.each do |campaign|
      first_list = campaign.audiences.order(:created_at).first
      puts "Consolidating campaign #{campaign.id} into audience #{first_list.id}"
      campaign.audiences.excluding(first_list).each do |sl|
        merged_audience_members += sl.audience_members.map do |sub|
          {
            audience_id: first_list.id,
            customer_id: sub.customer_id,
            state: sub.state,
            created_at: sub.created_at,
            updated_at: sub.updated_at,
            creator_id: sub.creator_id,
            updater_id: sub.updater_id
          }
        end
        audience_ids_to_delete << sl.id
        audience_member_ids_to_delete += sl.audience_member_ids
      end
    end
    msg = "Moving #{merged_audience_members.size} audience_members, deleting #{audience_ids_to_delete.size} lists"
    Rails.logger.info msg
    AudienceMember.insert_all(merged_audience_members) if merged_audience_members.any?
    AudienceMember.delete_by(id: audience_member_ids_to_delete)
    Audience.delete_by(id: audience_ids_to_delete)
    msg
  end
end

.options_for_selectArray<Array>

Returns [label, id] pairs for select dropdowns.

Returns:

  • (Array<Array>)

    [label, id] pairs for select dropdowns



209
210
211
212
213
# File 'app/models/campaign.rb', line 209

def self.options_for_select
  res = order(:name)
  res = yield(res) if block_given?
  res.order(:name).map { |c| [c.name_and_type, c.id] }
end

.outside_salesActiveRecord::Relation<Campaign>

A relation of Campaigns that are outside sales. Active Record Scope

Returns:

See Also:



104
# File 'app/models/campaign.rb', line 104

scope :outside_sales, -> { where(campaign_type: 'outside_sales') }

.states_for_selectObject

States for select.



221
222
223
# File 'app/models/campaign.rb', line 221

def self.states_for_select
  state_machines[:state].states.map { |s| [s.human_name, s.value] }
end

.with_campaign_emails_countActiveRecord::Relation<Campaign>

A relation of Campaigns that are with campaign emails count. Active Record Scope

Returns:

See Also:



112
113
114
115
116
117
# File 'app/models/campaign.rb', line 112

scope :with_campaign_emails_count, -> {
  select('campaigns.*')
    .select('COUNT(campaign_actions.id) as emails_count')
    .left_joins(:campaign_emails)
    .group('campaigns.id')
}

.with_email_statsActiveRecord::Relation<Campaign>

A relation of Campaigns that are with email stats. Active Record Scope

Returns:

See Also:



119
120
121
122
123
124
125
# File 'app/models/campaign.rb', line 119

scope :with_email_stats, -> {
  select('campaigns.*')
    .select('MAX(campaign_actions.created_at) as last_email_created_at')
    .select('COUNT(campaign_actions.id) as emails_count')
    .left_joins(:campaign_emails)
    .group('campaigns.id')
}

.with_last_created_at_emailActiveRecord::Relation<Campaign>

A relation of Campaigns that are with last created at email. Active Record Scope

Returns:

See Also:



106
107
108
109
110
111
# File 'app/models/campaign.rb', line 106

scope :with_last_created_at_email, -> {
  select('campaigns.*')
    .select('MAX(campaign_actions.created_at) as last_email_created_at')
    .left_joins(:campaign_emails)
    .group('campaigns.id')
}

Instance Method Details

#activitiesActiveRecord::Relation<Activity>

Returns the associated activities.

Returns:

  • (ActiveRecord::Relation<Activity>)

    the associated activities



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

has_many :activities

#activity_chain_typesActiveRecord::Relation<ActivityChainType>

Returns the associated activity chain types.

Returns:

  • (ActiveRecord::Relation<ActivityChainType>)

    the associated activity chain types



82
# File 'app/models/campaign.rb', line 82

has_many :activity_chain_types

#add_customer(customer) ⇒ Object

Add customer.

Parameters:



232
233
234
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
# File 'app/models/campaign.rb', line 232

def add_customer(customer)
  return AddCustomerResult.new(subscribed: false, message: 'Customer is GUEST and cannot be subscribed') if customer.guest?
  return AddCustomerResult.new(subscribed: false, message: 'Customer has no contact information') unless customer.contactable?

  audience = audiences.where(list_type: 'customer', name: "#{name} system added").first_or_create

  audience_member = audience.audience_members.where(customer_id: customer.id).first

  # Check customer against filter
  if customer_filter.nil? || customer_filter.applies_to_customer?(customer)
    if audience_member
      # make sure it is active and return
      audience_member.activate
    else
      begin
        # Joining an audience records a TOUCH; it no longer rewrites the
        # source of anything the customer already had. The touch is what
        # `campaign_influenced_invoices` joins against, so influence is
        # reported without destroying acquisition to record it.
        audience_member = audience.audience_members.create(customer_id: customer.id)
      rescue ActiveRecord::RecordNotUnique
        # Another process might have caused a race condition here
      end
    end
    res = AddCustomerResult.new(subscribed: true, audience_member: audience_member)
  else # Does not meet filter qualification
    # customer does not apply to filter in place, inactivate subscription if applicable
    if audience_member&.active?
      audience_member.deactivate
      'Existing audience_member was de-activated due to customer filter'
    end
    res = AddCustomerResult.new(subscribed: false, audience_member: audience_member)
  end
  res
end

#audience_member_countObject

Audience member count.



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

def audience_member_count
  audiences.sum(&:audience_member_count)
end

#audience_membersActiveRecord::Relation<AudienceMember>

Returns the associated audience members.

Returns:

  • (ActiveRecord::Relation<AudienceMember>)

    the associated audience members



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

has_many :audience_members, through: :audiences

#audiencesActiveRecord::Relation<Audience>

Returns the associated audiences.

Returns:

  • (ActiveRecord::Relation<Audience>)

    the associated audiences



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

has_and_belongs_to_many :audiences

#audiences_all_customerObject

Audiences all customer.



359
360
361
362
363
# File 'app/models/campaign.rb', line 359

def audiences_all_customer
  return unless audiences.any? && audiences.any? { |sl| ['customer'].exclude?(sl.list_type) }

  errors.add(:audience_ids, 'Only customer audiences are allowed for outside_sales and event campaigns')
end

#audiences_all_emailObject

Audiences all email.



352
353
354
355
356
# File 'app/models/campaign.rb', line 352

def audiences_all_email
  return unless audiences.any? && audiences.any? { |sl| %w[static dynamic].exclude?(sl.list_type) }

  errors.add(:audience_ids, 'Only email audiences are allowed for email_marketing campaigns')
end

#campaign_deliveriesActiveRecord::Relation<CampaignDelivery>

Returns the associated campaign deliveries.

Returns:

  • (ActiveRecord::Relation<CampaignDelivery>)

    the associated campaign deliveries



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

has_many :campaign_deliveries, through: :campaign_emails

#campaign_emailsActiveRecord::Relation<CampaignEmail>

Returns the associated campaign emails.

Returns:

  • (ActiveRecord::Relation<CampaignEmail>)

    the associated campaign emails



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

has_many :campaign_emails, dependent: :destroy, inverse_of: :campaign

#campaign_source_name_availableObject

generate_source creates an active child Source named name under the
Email / Outside Sales parent. The Source's ltree full_name
("Campaigns > Email > ") is built in an after_create and is uniquely
indexed among active sources (index_sources_on_full_name_unique_active), so
a duplicate campaign name would raise RecordNotUnique deep in that rebuild —
a 500. Reject it up front as a form error instead (AppSignal #5594).



319
320
321
322
323
324
325
326
327
# File 'app/models/campaign.rb', line 319

def campaign_source_name_available
  return if name.blank?

  parent_id = outside_sales? ? Source.outside_sales_campaign_id : Source.email_campaign_id
  return if parent_id.blank?
  return unless Source.where(parent_id: parent_id, name: name, visibility: 'active').exists?

  errors.add(:name, 'is already used by an active campaign source')
end

#can_be_deativated?Boolean

Returns whether the record can be deativated.

Returns:

  • (Boolean)

    whether the record can be deativated



290
291
292
293
294
295
296
# File 'app/models/campaign.rb', line 290

def can_be_deativated?
  return false if created_at < 1.month.ago

  last_transmitted = campaign_emails.where.not(last_transmitted: nil).maximum(:last_transmitted)

  campaign_emails.empty? || (last_transmitted && (last_transmitted > 1.month.ago))
end

#check_for_active_campaign_emailsObject

Check for active campaign emails.



330
331
332
333
334
335
336
337
338
339
# File 'app/models/campaign.rb', line 330

def check_for_active_campaign_emails
  if campaign_emails.empty?
    true
  elsif campaign_emails.any? { |ce| !ce.can_be_destroyed? }
    errors.add :base, 'cannot delete campaign which has emails which have already been sent'
    false
  else
    true
  end
end

#customer_filterCustomerFilter?

Returns the customer filter this record belongs to.

Returns:

  • (CustomerFilter, nil)

    the customer filter this record belongs to



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

belongs_to :customer_filter, optional: true

#delivery_statsObject

Delivery stats.



150
151
152
# File 'app/models/campaign.rb', line 150

def delivery_stats
  CampaignEmail.delivery_stats(campaign_deliveries)
end

#email_marketing?Boolean

Returns whether the record email marketing.

Returns:

  • (Boolean)

    whether the record email marketing



347
348
349
# File 'app/models/campaign.rb', line 347

def email_marketing?
  campaign_type == 'email_marketing'
end

#generate_sourceObject

Generate source.



299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'app/models/campaign.rb', line 299

def generate_source
  s = Source.new
  s.parent_id = (campaign_type == 'outside_sales' ? Source.outside_sales_campaign_id : Source.email_campaign_id)
  s.name = name
  s.referral_code = s.generate_ref_code
  if s.save
    self.source_id = s.id
    true
  else
    errors.add(:base, "Unable to create source. Error: #{s.errors.full_messages}")
    throw :abort
  end
end

#merge_audiencesObject

Merge audiences.



155
156
157
# File 'app/models/campaign.rb', line 155

def merge_audiences
  self.class.merge_audiences(campaign_ids: id)
end

#name_and_typeObject

Name and type.



269
270
271
# File 'app/models/campaign.rb', line 269

def name_and_type
  "#{name} [#{campaign_type}]"
end

#outside_sales?Boolean

Returns whether the record outside sales.

Returns:

  • (Boolean)

    whether the record outside sales



342
343
344
# File 'app/models/campaign.rb', line 342

def outside_sales?
  campaign_type == 'outside_sales'
end

#sourceSource?

Returns the source this record belongs to.

Returns:

  • (Source, nil)

    the source this record belongs to



66
# File 'app/models/campaign.rb', line 66

belongs_to :source, optional: true

#source_assignment_window_open_for?(customer, at: Time.current) ⇒ Boolean

Whether the customer's per-member source-assignment window is open at
the given time — i.e. they joined the audience (or the campaign
activated) and their last campaign touch is within
auto_assign_window_days. Used by the creation-default path so a
dormant membership (e.g. a 2018 drip enrollment with no touches
since) stops claiming new opportunities/orders.

Parameters:

  • customer (Customer)
  • at (Date, Time) (defaults to: Time.current)

    evaluation time (historical for marketing-tab display)

Returns:

  • (Boolean)


397
398
399
400
401
402
403
404
# File 'app/models/campaign.rb', line 397

def source_assignment_window_open_for?(customer, at: Time.current)
  joined_at = audience_members.where(customer_id: customer.id).active.minimum(:created_at) ||
              active_since&.beginning_of_day
  at_time = at.in_time_zone
  return false unless joined_at && joined_at <= at_time.end_of_day

  source_assignment_window_end(customer, joined_at: joined_at, up_to: at_time.end_of_day) >= at_time
end

#to_sString

Returns string representation of the record.

Returns:

  • (String)

    string representation of the record



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

def to_s
  "#{name} [#{id}]"
end

#unique_audience_membersObject

Unique audience members.



279
280
281
282
283
284
285
286
287
# File 'app/models/campaign.rb', line 279

def unique_audience_members
  Campaign.joins("LEFT OUTER JOIN audiences_campaigns ON audiences_campaigns.campaign_id = campaigns.id
                  LEFT OUTER JOIN audiences ON audiences.id = audiences_campaigns.audience_id
                  LEFT OUTER JOIN audience_members ON audience_members.audience_id = audiences.id
                  LEFT OUTER JOIN view_customers ON audience_members.customer_id = view_customers.id
                    AND view_customers.state != 'guest'")
          .where(campaigns: { id: id })
          .count('distinct view_customers.id')
end

#update_source_nameObject

Update source name.



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

def update_source_name
  source&.update(name: name)
end