Class: AdActionItem
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- AdActionItem
- 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 asstale. - Disposition is a human's:
accepted/in_progress/completed/
ignoredare 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.
stalemeans 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
- #category ⇒ String readonly
- #effort ⇒ String? readonly
- #fingerprint ⇒ String readonly
- #impact ⇒ String? readonly
- #provider ⇒ String readonly
- #status ⇒ String readonly
- #title ⇒ String readonly
Belongs to collapse
Class Method Summary collapse
-
.actionable ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are actionable.
-
.by_effort ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are by effort.
-
.by_impact ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are by impact.
-
.by_priority ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are by priority.
-
.completed ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are completed.
-
.for_category ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are for category.
-
.for_impact ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are for impact.
-
.for_provider ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are for provider.
-
.for_status ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are for status.
-
.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. -
.human_category_label(cat) ⇒ String
Display label, e.g.
-
.human_provider_label(provider) ⇒ String
Display label, e.g.
-
.ignored ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are ignored.
-
.pending ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are pending.
-
.provider_icon(provider) ⇒ Array(String, Symbol)
Font Awesome icon for a provider, as
[name, family]forfa_icon. -
.resolved ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are resolved.
-
.stale ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are stale.
-
.status_attrs(new_status) ⇒ Hash
The attributes a status change writes.
Instance Method Summary collapse
-
#actionable? ⇒ Boolean
True while the item still wants attention.
- #category_label ⇒ String
-
#evidence ⇒ String?
Human-readable evidence line built from whatever the rule stored, so the UI doesn't need to know each category's
detailsshape. -
#impact_badge_class ⇒ String
Bootstrap badge class for #impact.
- #mark_completed!(notes_text = nil) ⇒ Boolean
- #mark_ignored!(notes_text = nil) ⇒ Boolean
- #provider_label ⇒ String
-
#resolved? ⇒ Boolean
True once someone has completed or dismissed it.
-
#status_badge_class ⇒ String
Bootstrap badge class for #status.
-
#sunny_prompt ⇒ String
Prompt seeding a Sunny conversation about this item.
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
#category ⇒ String (readonly)
80 |
# File 'app/models/ad_action_item.rb', line 80 validates :category, presence: true, inclusion: { in: CATEGORIES } |
#effort ⇒ String? (readonly)
86 |
# File 'app/models/ad_action_item.rb', line 86 validates :effort, inclusion: { in: EFFORTS }, allow_nil: true |
#fingerprint ⇒ String (readonly)
90 |
# File 'app/models/ad_action_item.rb', line 90 validates :fingerprint, presence: true, uniqueness: { scope: %i[source_id category] } |
#impact ⇒ String? (readonly)
84 |
# File 'app/models/ad_action_item.rb', line 84 validates :impact, inclusion: { in: IMPACTS }, allow_nil: true |
#provider ⇒ String (readonly)
78 |
# File 'app/models/ad_action_item.rb', line 78 validates :provider, presence: true, inclusion: { in: Source::CAMPAIGN_PROVIDERS } |
#status ⇒ String (readonly)
82 |
# File 'app/models/ad_action_item.rb', line 82 validates :status, presence: true, inclusion: { in: STATUSES } |
#title ⇒ String (readonly)
88 |
# File 'app/models/ad_action_item.rb', line 88 validates :title, presence: true |
Class Method Details
.actionable ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are actionable. Active Record Scope
98 |
# File 'app/models/ad_action_item.rb', line 98 scope :actionable, -> { where(status: %w[pending accepted in_progress]) } |
.by_effort ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are by effort. Active Record Scope
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_impact ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are by impact. Active Record Scope
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_priority ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are by priority. Active Record Scope
109 |
# File 'app/models/ad_action_item.rb', line 109 scope :by_priority, -> { by_impact.by_effort.order(updated_at: :desc) } |
.completed ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are completed. Active Record Scope
93 |
# File 'app/models/ad_action_item.rb', line 93 scope :completed, -> { where(status: 'completed') } |
.for_category ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are for category. Active Record Scope
102 |
# File 'app/models/ad_action_item.rb', line 102 scope :for_category, ->(category) { category.presence ? where(category:) : all } |
.for_impact ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are for impact. Active Record Scope
104 |
# File 'app/models/ad_action_item.rb', line 104 scope :for_impact, ->(impact) { impact.presence ? where(impact:) : all } |
.for_provider ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are for provider. Active Record Scope
101 |
# File 'app/models/ad_action_item.rb', line 101 scope :for_provider, ->(provider) { provider.presence ? where(provider:) : all } |
.for_status ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are for status. Active Record Scope
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.
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".
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".
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 |
.ignored ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are ignored. Active Record Scope
94 |
# File 'app/models/ad_action_item.rb', line 94 scope :ignored, -> { where(status: 'ignored') } |
.pending ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are pending. Active Record Scope
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.
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 |
.resolved ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are resolved. Active Record Scope
100 |
# File 'app/models/ad_action_item.rb', line 100 scope :resolved, -> { where(status: %w[completed ignored]) } |
.stale ⇒ ActiveRecord::Relation<AdActionItem>
A relation of AdActionItems that are stale. Active Record Scope
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.
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.
158 159 160 |
# File 'app/models/ad_action_item.rb', line 158 def actionable? status.in?(%w[pending accepted in_progress]) end |
#category_label ⇒ String
193 194 195 |
# File 'app/models/ad_action_item.rb', line 193 def category_label self.class.human_category_label(category) end |
#evidence ⇒ String?
Human-readable evidence line built from whatever the rule stored, so the UI
doesn't need to know each category's details shape.
228 229 230 |
# File 'app/models/ad_action_item.rb', line 228 def evidence details['evidence'].presence end |
#impact_badge_class ⇒ String
Returns 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
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
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_label ⇒ 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.
163 164 165 |
# File 'app/models/ad_action_item.rb', line 163 def resolved? status.in?(%w[completed ignored]) end |
#status_badge_class ⇒ String
Returns 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_prompt ⇒ String
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.
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}" = [] << "Impact: #{impact}" if impact.present? << "Effort: #{effort}" if effort.present? lines << .join(' | ') if .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 |