Class: AdActionItem

Inherits:
ApplicationRecord show all
Defined in:
app/models/ad_action_item.rb

Overview

== Schema Information

Table name: ad_action_items
Database name: primary

id :bigint not null, primary key
analysis_generation :integer default(0), not null
category :string not null
completed_at :datetime
details :jsonb not null
effort :string
fingerprint :string not null
impact :string
last_seen_at :datetime
notes :text
provider :string not null
status :string default("pending"), not null
title :string not null
created_at :datetime not null
updated_at :datetime not null
source_id :bigint not null

Indexes

idx_ad_action_items_unique_per_source_category (source_id,category,fingerprint) UNIQUE
index_ad_action_items_on_provider (provider)
index_ad_action_items_on_provider_and_status (provider,status)
index_ad_action_items_on_source_id (source_id)
index_ad_action_items_on_status (status)

Foreign Keys

fk_rails_... (source_id => sources.id)

One thing worth doing about one ad campaign — the ads counterpart to
SiteMapRecommendation (SEO) and ListingIssue (retail listings).

Items parent on the campaign's Source, the same key spend
(SourceDataPoint) and revenue (invoices) already attribute to, so an item's
evidence is a join rather than a reconciliation.

Two halves, deliberately kept apart:

  • Detection is Marketing::Ads::ActionItemSync's: it upserts what the
    numbers currently say on the (source, category, fingerprint) key and marks
    items the rules no longer fire for as stale.
  • Disposition is a human's: accepted / in_progress / completed /
    ignored are set from the CRM and the sync never overwrites them.

That split is why this class carries a status lifecycle at all where
ListingIssue — which is purely detection-derived — does not.

Constant Summary collapse

CATEGORIES =

Rule-based categories. Each is produced by one rule in
Marketing::Ads::ActionItemSync; LLM-generated categories (budget
reallocation, negative keywords, creative) land later.

%w[
  wasted_spend
  spend_anomaly
  low_delivery
  underperforming_roas
  tracking_mismatch
].freeze
STATUSES =

Lifecycle, mirroring SiteMapRecommendation. stale means the rule stopped
firing before anyone acted — the numbers moved on, not that we fixed it.

%w[pending accepted in_progress completed ignored stale].freeze
IMPACTS =

Expected size of the win.

%w[high medium low].freeze
EFFORTS =

Expected cost of getting it.

%w[high medium low].freeze

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Class Method Summary collapse

Instance Method Summary collapse

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

#categoryString (readonly)

Returns:

  • (String)


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

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

#effortString? (readonly)

Returns:

  • (String, nil)


86
# File 'app/models/ad_action_item.rb', line 86

validates :effort, inclusion: { in: EFFORTS }, allow_nil: true

#fingerprintString (readonly)

Returns:

  • (String)


90
# File 'app/models/ad_action_item.rb', line 90

validates :fingerprint, presence: true, uniqueness: { scope: %i[source_id category] }

#impactString? (readonly)

Returns:

  • (String, nil)


84
# File 'app/models/ad_action_item.rb', line 84

validates :impact, inclusion: { in: IMPACTS }, allow_nil: true

#providerString (readonly)

Returns:

  • (String)


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

validates :provider, presence: true, inclusion: { in: Source::CAMPAIGN_PROVIDERS }

#statusString (readonly)

Returns:

  • (String)


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

validates :status, presence: true, inclusion: { in: STATUSES }

#titleString (readonly)

Returns:

  • (String)


88
# File 'app/models/ad_action_item.rb', line 88

validates :title, presence: true

Class Method Details

.actionableActiveRecord::Relation<AdActionItem>

A relation of AdActionItems that are actionable. Active Record Scope

Returns:

See Also:



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

scope :actionable, -> { where(status: %w[pending accepted in_progress]) }

.by_effortActiveRecord::Relation<AdActionItem>

A relation of AdActionItems that are by effort. Active Record Scope

Returns:

See Also:



107
# File 'app/models/ad_action_item.rb', line 107

scope :by_effort, -> { order(Arel.sql("CASE effort WHEN 'low' THEN 0 WHEN 'medium' THEN 1 WHEN 'high' THEN 2 ELSE 3 END")) }

.by_impactActiveRecord::Relation<AdActionItem>

A relation of AdActionItems that are by impact. Active Record Scope

Returns:

See Also:



106
# File 'app/models/ad_action_item.rb', line 106

scope :by_impact, -> { order(Arel.sql("CASE impact WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END")) }

.by_priorityActiveRecord::Relation<AdActionItem>

A relation of AdActionItems that are by priority. Active Record Scope

Returns:

See Also:



109
# File 'app/models/ad_action_item.rb', line 109

scope :by_priority, -> { by_impact.by_effort.order(updated_at: :desc) }

.completedActiveRecord::Relation<AdActionItem>

A relation of AdActionItems that are completed. Active Record Scope

Returns:

See Also:



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

scope :completed, -> { where(status: 'completed') }

.for_categoryActiveRecord::Relation<AdActionItem>

A relation of AdActionItems that are for category. Active Record Scope

Returns:

See Also:



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

scope :for_category, ->(category) { category.presence ? where(category:) : all }

.for_impactActiveRecord::Relation<AdActionItem>

A relation of AdActionItems that are for impact. Active Record Scope

Returns:

See Also:



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

scope :for_impact, ->(impact) { impact.presence ? where(impact:) : all }

.for_providerActiveRecord::Relation<AdActionItem>

A relation of AdActionItems that are for provider. Active Record Scope

Returns:

See Also:



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

scope :for_provider, ->(provider) { provider.presence ? where(provider:) : all }

.for_statusActiveRecord::Relation<AdActionItem>

A relation of AdActionItems that are for status. Active Record Scope

Returns:

See Also:



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

scope :for_status, ->(status) { status.presence ? where(status:) : all }

.generate_fingerprint(category, key) ⇒ String

Stable identity for an item within (source, category), so a re-run
refreshes the existing row instead of stacking a duplicate. Rules pass the
dimension that makes the item distinct (the metric it fired on, the window
it covers) — NOT a number that moves nightly, which would mint a new row
every sync.

Parameters:

  • category (String)
  • key (String)

    rule-supplied discriminator

Returns:

  • (String)

    64-char hex digest



120
121
122
# File 'app/models/ad_action_item.rb', line 120

def self.generate_fingerprint(category, key)
  Digest::SHA256.hexdigest("#{category}|#{key.to_s.strip.downcase}")[0..63]
end

.human_category_label(cat) ⇒ String

Returns display label, e.g. "Underperforming ROAS".

Parameters:

  • cat (String)

Returns:

  • (String)

    display label, e.g. "Underperforming ROAS"



126
127
128
# File 'app/models/ad_action_item.rb', line 126

def self.human_category_label(cat)
  cat.to_s.titleize.gsub('Roas', 'ROAS').gsub('Cpc', 'CPC')
end

.human_provider_label(provider) ⇒ String

Returns display label, e.g. "Microsoft Ads".

Parameters:

Returns:

  • (String)

    display label, e.g. "Microsoft Ads"



132
133
134
135
136
137
138
139
# File 'app/models/ad_action_item.rb', line 132

def self.human_provider_label(provider)
  case provider
  when 'openai_ads' then 'ChatGPT Ads'
  when 'microsoft_ads' then 'Microsoft Ads'
  when 'amazon_ads' then 'Amazon Ads'
  else "#{provider.to_s.titleize} Ads"
  end
end

.ignoredActiveRecord::Relation<AdActionItem>

A relation of AdActionItems that are ignored. Active Record Scope

Returns:

See Also:



94
# File 'app/models/ad_action_item.rb', line 94

scope :ignored, -> { where(status: 'ignored') }

.pendingActiveRecord::Relation<AdActionItem>

A relation of AdActionItems that are pending. Active Record Scope

Returns:

See Also:



92
# File 'app/models/ad_action_item.rb', line 92

scope :pending, -> { where(status: 'pending') }

.provider_icon(provider) ⇒ Array(String, Symbol)

Font Awesome icon for a provider, as [name, family] for fa_icon. OpenAI
has no Sharp brand glyph in the bundled set, so it falls back to solid.

Parameters:

  • provider (String)

Returns:

  • (Array(String, Symbol))


146
147
148
149
150
151
152
153
154
155
# File 'app/models/ad_action_item.rb', line 146

def self.provider_icon(provider)
  case provider
  when 'google' then ['google', :brands]
  when 'facebook' then ['facebook', :brands]
  when 'pinterest' then ['pinterest', :brands]
  when 'microsoft_ads' then ['microsoft', :brands]
  when 'amazon_ads' then ['amazon', :brands]
  else ['robot', :solid]
  end
end

.resolvedActiveRecord::Relation<AdActionItem>

A relation of AdActionItems that are resolved. Active Record Scope

Returns:

See Also:



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

scope :resolved, -> { where(status: %w[completed ignored]) }

.staleActiveRecord::Relation<AdActionItem>

A relation of AdActionItems that are stale. Active Record Scope

Returns:

See Also:



95
# File 'app/models/ad_action_item.rb', line 95

scope :stale, -> { where(status: 'stale') }

.status_attrs(new_status) ⇒ Hash

The attributes a status change writes. completed_at tracks the status
rather than accumulating: demoting a completed item back to in-progress has
to clear it, or the timestamp outlives the fact it recorded.

A class method because the bulk path uses update_all and cannot call the
instance methods below — and the rule has to have exactly one definition.

Parameters:

  • new_status (String)

    one of STATUSES

Returns:

  • (Hash)


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

def self.status_attrs(new_status)
  { status: new_status, completed_at: new_status == 'completed' ? Time.current : nil }
end

Instance Method Details

#actionable?Boolean

Returns true while the item still wants attention.

Returns:

  • (Boolean)

    true while the item still wants attention.



158
159
160
# File 'app/models/ad_action_item.rb', line 158

def actionable?
  status.in?(%w[pending accepted in_progress])
end

#category_labelString

Returns:

  • (String)


193
194
195
# File 'app/models/ad_action_item.rb', line 193

def category_label
  self.class.human_category_label(category)
end

#evidenceString?

Human-readable evidence line built from whatever the rule stored, so the UI
doesn't need to know each category's details shape.

Returns:

  • (String, nil)


228
229
230
# File 'app/models/ad_action_item.rb', line 228

def evidence
  details['evidence'].presence
end

#impact_badge_classString

Returns Bootstrap badge class for #impact.

Returns:

  • (String)

    Bootstrap badge class for #impact



203
204
205
206
207
208
209
210
# File 'app/models/ad_action_item.rb', line 203

def impact_badge_class
  case impact
  when 'high'   then 'text-bg-danger'
  when 'medium' then 'text-bg-warning'
  when 'low'    then 'text-bg-secondary'
  else 'text-bg-light'
  end
end

#mark_completed!(notes_text = nil) ⇒ Boolean

Parameters:

  • notes_text (String, nil) (defaults to: nil)

Returns:

  • (Boolean)


182
183
184
# File 'app/models/ad_action_item.rb', line 182

def mark_completed!(notes_text = nil)
  update!(self.class.status_attrs('completed').merge(notes: notes_text || notes))
end

#mark_ignored!(notes_text = nil) ⇒ Boolean

Parameters:

  • notes_text (String, nil) (defaults to: nil)

Returns:

  • (Boolean)


188
189
190
# File 'app/models/ad_action_item.rb', line 188

def mark_ignored!(notes_text = nil)
  update!(self.class.status_attrs('ignored').merge(notes: notes_text || notes))
end

#provider_labelString

Returns:

  • (String)


198
199
200
# File 'app/models/ad_action_item.rb', line 198

def provider_label
  self.class.human_provider_label(provider)
end

#resolved?Boolean

Returns true once someone has completed or dismissed it.

Returns:

  • (Boolean)

    true once someone has completed or dismissed it.



163
164
165
# File 'app/models/ad_action_item.rb', line 163

def resolved?
  status.in?(%w[completed ignored])
end

#sourceSource

Returns:



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

belongs_to :source

#status_badge_classString

Returns Bootstrap badge class for #status.

Returns:

  • (String)

    Bootstrap badge class for #status



213
214
215
216
217
218
219
220
221
222
# File 'app/models/ad_action_item.rb', line 213

def status_badge_class
  case status
  when 'pending'     then 'text-bg-info'
  when 'accepted'    then 'text-bg-primary'
  when 'in_progress' then 'text-bg-warning'
  when 'completed'   then 'text-bg-success'
  when 'ignored'     then 'text-bg-secondary'
  else 'text-bg-light'
  end
end

#sunny_promptString

Prompt seeding a Sunny conversation about this item. Sunny's Microsoft Ads
write tools are gated on the microsoft_ad_specialist role, so for that
provider this can end in an actual change; elsewhere it's advisory.

Returns:

  • (String)


237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'app/models/ad_action_item.rb', line 237

def sunny_prompt
  lines = ["I'd like to work on this #{provider_label} action item for campaign \"#{source.name}\":", '']
  lines << "**#{category_label}**: #{title}"
  meta = []
  meta << "Impact: #{impact}" if impact.present?
  meta << "Effort: #{effort}" if effort.present?
  lines << meta.join(' | ') if meta.any?
  lines << "\nEvidence: #{evidence}" if evidence.present?
  lines << "\nCampaign ID: #{source.campaign_external_id}" if source.campaign_external_id.present?
  lines << ''
  lines << 'Please review the campaign and recommend specific changes to address this.'
  lines.join("\n")
end