Status: โ API Approval Received - Implementation In Progress
Last Updated: 2025-11-24
google-ads-googleads v28.0 to GemfileGemfile:62Created three migration files following the Search Console pattern:
1. google_ads_campaigns (20251124000000_create_google_ads_campaigns.rb)
- campaign_id (string, unique, indexed)
- campaign_name (string, indexed)
- campaign_type (string)
- campaign_status (string, indexed)
- location (string, indexed)
- currency_code (string, default: "USD")
- login_customer_id (string)
2. google_ads_keywords (20251124001000_create_google_ads_keywords.rb)
- google_ads_campaign_id (foreign key)
- keyword_text (string, indexed)
- match_type (string)
- spend_micros (bigint, indexed) # Google Ads uses micros (1 million = $1)
- clicks (integer)
- impressions (integer)
- conversions (integer, indexed)
- average_cpc_micros (bigint)
- cost_per_conversion_micros (bigint)
- date (date, indexed)
3. google_ads_daily_reports (20251124002000_create_google_ads_daily_reports.rb)
- report_date (date, unique, indexed)
- location (string, indexed)
- total_spend (decimal, indexed)
- total_clicks (integer)
- total_impressions (integer)
- total_conversions (integer)
- new_customers_count (integer)
- top_campaigns (jsonb array)
- top_keywords (jsonb array)
- anomaly_flags (jsonb hash)
- ai_analysis (jsonb hash) # For ConsensusAiService results
Create three Active Record models with validations, scopes, and associations:
# app/models/google_ads_campaign.rb
class GoogleAdsCampaign < ApplicationRecord
has_many :google_ads_keywords, dependent: :destroy
validates :campaign_id, presence: true, uniqueness: true
validates :campaign_name, presence: true
scope :active, -> { where(campaign_status: 'ENABLED') }
scope :by_location, ->(location) { where(location: location) }
scope :recent, -> { order(updated_at: :desc) }
end
# app/models/google_ads_keyword.rb
class GoogleAdsKeyword < ApplicationRecord
belongs_to :google_ads_campaign
validates :keyword_text, presence: true
validates :date, presence: true
scope :for_date_range, ->(start_date, end_date) {
where(date: start_date..end_date)
}
scope :high_spend_zero_conversions, -> {
where('spend_micros > ? AND conversions = 0', 50_000_000) # $50+
.order(spend_micros: :desc)
}
# Helper to convert micros to dollars
def spend_dollars
spend_micros / 1_000_000.0
end
def average_cpc_dollars
average_cpc_micros / 1_000_000.0
end
end
# app/models/google_ads_daily_report.rb
class GoogleAdsDailyReport < ApplicationRecord
validates :report_date, presence: true, uniqueness: true
scope :recent, -> { order(report_date: :desc) }
scope :for_date_range, ->(start_date, end_date) {
where(report_date: start_date..end_date)
}
scope :with_ai_analysis, -> { where.not(ai_analysis: nil) }
# Calculate cost per acquisition
def cpa
return 0 if total_conversions.zero?
total_spend / total_conversions
end
# Check if analysis is stale (older than 7 days)
def stale_analysis?
return true if ai_analysis.blank?
analyzed_at = ai_analysis.dig('analyzed_at')
return true if analyzed_at.nil?
Time.parse(analyzed_at) < 7.days.ago
end
end
Mirror the SearchConsoleService pattern with OAuth and API calls:
# app/services/google_ads_service.rb
require 'google/ads/google_ads'
class GoogleAdsService
CUSTOMER_ID = ENV.fetch('GOOGLE_ADS_CUSTOMER_ID', '623-323-9884').delete('-')
attr_reader :client
def initialize
@client = Google::Ads::GoogleAds::GoogleAdsClient.new do |config|
config.client_id = ENV.fetch('GOOGLE_OAUTH_CLIENT_ID')
config.client_secret = ENV.fetch('GOOGLE_OAUTH_CLIENT_SECRET')
config.developer_token = ENV.fetch('GOOGLE_ADS_DEVELOPER_TOKEN')
config.refresh_token = get_refresh_token
config.login_customer_id = CUSTOMER_ID
end
end
# Sync campaigns for date range
def sync_campaigns(start_date: 7.days.ago.to_date, end_date: Date.yesterday)
# Implementation here
end
# Sync keyword performance
def sync_keywords(start_date: 7.days.ago.to_date, end_date: Date.yesterday)
# Implementation here
end
# Sync daily aggregate reports
def sync_daily_report(date: Date.yesterday)
# Implementation here
end
# Sync all data
def sync_all(start_date: 7.days.ago.to_date, end_date: Date.yesterday)
campaigns_count = sync_campaigns(start_date: start_date, end_date: end_date)
keywords_count = sync_keywords(start_date: start_date, end_date: end_date)
reports_count = 0
(start_date..end_date).each do |date|
sync_daily_report(date: date)
reports_count += 1
end
{
campaigns: campaigns_count,
keywords: keywords_count,
daily_reports: reports_count
}
end
private
def get_refresh_token
oauth_credential = OauthCredential.for_provider_and_user(
provider: "google_ads",
user_id: "default"
)
unless oauth_credential.persisted? && oauth_credential.refresh_token.present?
raise "No OAuth credentials found. Please authorize at: /admin/oauth/authorize/google_ads"
end
oauth_credential.refresh_token
end
end
# app/jobs/sync_google_ads_data_job.rb
class SyncGoogleAdsDataJob < ApplicationJob
queue_as :default
def perform
service = GoogleAdsService.new
result = service.sync_all(
start_date: 7.days.ago.to_date,
end_date: Date.yesterday
)
Rails.logger.info "Google Ads sync completed: #{result.inspect}"
rescue => e
Rails.logger.error "Google Ads sync failed: #{e.message}"
raise
end
end
# app/jobs/weekly_google_ads_analysis_job.rb
class WeeklyGoogleAdsAnalysisJob < ApplicationJob
queue_as :default
def perform
# Get last 7 days of data
reports = GoogleAdsDailyReport.for_date_range(7.days.ago.to_date, Date.yesterday)
# Prepare data for AI analysis
analysis_data = {
total_spend: reports.sum(:total_spend),
total_clicks: reports.sum(:total_clicks),
total_conversions: reports.sum(:total_conversions),
# ... more metrics
}
# Use ConsensusAiService (Gemini + Grok)
consensus_service = ConsensusAiService.new
result = consensus_service.analyze_google_ads_performance(analysis_data)
# Store in most recent daily report
latest_report = reports.last
latest_report.update!(ai_analysis: result)
# Send email notification
AdsInsightsMailer.weekly_report(latest_report).deliver_later
Rails.logger.info "Google Ads AI analysis completed"
rescue => e
Rails.logger.error "Google Ads AI analysis failed: #{e.message}"
raise
end
end
# app/controllers/admin/google_ads_controller.rb
module Admin
class GoogleAdsController < ApplicationController
def index
@date_range = parse_date_range
@has_data = GoogleAdsKeyword.exists?
return render :no_data unless @has_data
# Latest analytics
@latest_report = GoogleAdsDailyReport.recent.first
# Latest AI analysis
@latest_ai_analysis = GoogleAdsDailyReport.with_ai_analysis.recent.first
# Campaign performance
@campaigns = GoogleAdsCampaign.active.recent.limit(10)
# Top spending keywords
@top_keywords = GoogleAdsKeyword
.for_date_range(@date_range[:start], @date_range[:end])
.group(:keyword_text)
.select('keyword_text, SUM(spend_micros) as total_spend, SUM(conversions) as total_conversions')
.order('total_spend DESC')
.limit(10)
# Negative keyword candidates (high spend, zero conversions)
@negative_keyword_candidates = GoogleAdsKeyword
.for_date_range(@date_range[:start], @date_range[:end])
.high_spend_zero_conversions
.limit(10)
# Daily trends
@daily_trends = GoogleAdsDailyReport
.for_date_range(30.days.ago.to_date, Date.yesterday)
.order(report_date: :asc)
end
def sync_now
begin
service = GoogleAdsService.new
result = service.sync_all(start_date: 7.days.ago.to_date, end_date: Date.yesterday)
redirect_to admin_google_ads_index_path,
notice: "Successfully synced #{result[:campaigns]} campaigns, #{result[:keywords]} keywords, and #{result[:daily_reports]} daily reports."
rescue StandardError => e
redirect_to admin_google_ads_index_path,
alert: "Sync failed: #{e.message}"
end
end
def analyze_now
begin
WeeklyGoogleAdsAnalysisJob.perform_later
redirect_to admin_google_ads_index_path,
notice: "AI analysis started. Results will appear in a few moments."
rescue StandardError => e
redirect_to admin_google_ads_index_path,
alert: "Analysis failed: #{e.message}"
end
end
private
def parse_date_range
start_date = params[:start_date].present? ? Date.parse(params[:start_date]) : 30.days.ago.to_date
end_date = params[:end_date].present? ? Date.parse(params[:end_date]) : Date.yesterday
{ start: start_date, end: end_date }
rescue ArgumentError
{ start: 30.days.ago.to_date, end: Date.yesterday }
end
end
end
# config/routes.rb
namespace :admin do
namespace :google_ads do
get '/', to: 'google_ads#index', as: :index
post '/sync_now', to: 'google_ads#sync_now'
post '/analyze_now', to: 'google_ads#analyze_now'
end
end
# config/recurring.yml
sync_google_ads_data:
schedule: at 4am every day
job: SyncGoogleAdsDataJob
weekly_google_ads_analysis:
schedule: at 8:30am every Monday
job: WeeklyGoogleAdsAnalysisJob
GOOGLE_ADS_CUSTOMER_ID="623-323-9884"
GOOGLE_ADS_DEVELOPER_TOKEN="vcq_TYSpAeWx3XR0BAZaDQ"
GOOGLE_OAUTH_CLIENT_ID="<from Google Cloud Console>"
GOOGLE_OAUTH_CLIENT_SECRET="<from Google Cloud Console>"
/admin/oauth/authorize/google_adsoauth_credentials table with provider: 'google_ads'Once implementation is complete:
bin/rails db:migrateGoogleAdsService.new.sync_all{
campaign_id: "12345678901",
campaign_name: "LA Dumpster Rental - Search",
campaign_type: "SEARCH",
campaign_status: "ENABLED",
location: "Los Angeles, CA",
currency_code: "USD"
}
{
keyword_text: "dumpster rental los angeles",
match_type: "EXACT",
spend_micros: 340_500_000, # $340.50
clicks: 48,
impressions: 892,
conversions: 3,
average_cpc_micros: 7_093_750, # $7.09
date: "2025-11-23"
}
Once both Google Ads and Search Console are complete, we can build unified views:
Keyword: "dumpster rental los angeles"
โโโ Organic: Rank #2, 450 clicks/month, $0 cost
โโโ Paid: $340/month, 48 clicks, 3 conversions
๐ก AI Recommendation: "You rank #2 organically. Consider reducing paid bid by 50% or adding as negative keyword if organic CTR is high."
app/services/search_console_service.rbapp/services/consensus_ai_service.rbProject Owner: Robert O'Neill Email: robert@quarry.app Google Ads MCC ID: 623-323-9884 Developer Token: vcq_TYSpAeWx3XR0BAZaDQ
Status: โ API Approved - Ready to implement!