diff --git a/app/assets/javascripts/initializePage.js.erb b/app/assets/javascripts/initializePage.js.erb index 317839055..2ebd6b221 100644 --- a/app/assets/javascripts/initializePage.js.erb +++ b/app/assets/javascripts/initializePage.js.erb @@ -52,6 +52,7 @@ function callInitalizers(){ nextPage = 0; fetching = false; done = false; + adClicked = false; setTimeout(function(){ done = false; },300); diff --git a/app/assets/javascripts/initializers/initializeBaseTracking.js.erb b/app/assets/javascripts/initializers/initializeBaseTracking.js.erb index 14d0147e2..cfff836c2 100644 --- a/app/assets/javascripts/initializers/initializeBaseTracking.js.erb +++ b/app/assets/javascripts/initializers/initializeBaseTracking.js.erb @@ -141,7 +141,20 @@ function trackCustomImpressions() { } }, 15000) } - }, 1500) + + // display add + var adBox = document.getElementById('sponsorship-arbitrary-display-widget') + if (adBox + && tokenMeta + && !isBot + && windowBigEnough + && checkUserLoggedIn()) { + var csrfToken = tokenMeta.getAttribute('content'); + trackAdImpression(csrfToken, adBox) + adBox.removeEventListener('click', trackAdClick, false ); + adBox.addEventListener('click', function() { trackAdClick(csrfToken, adBox) }); + } + }, 1800) } function trackHTMLVariantTrial(dataBody, csrfToken) { @@ -192,5 +205,45 @@ function trackFifteenSecondsOnPage(articleId, csrfToken) { }, credentials: 'same-origin', }) +} +function trackAdImpression(token, adBox) { + var dataBody = { + display_ad_event: { + display_ad_id: adBox.dataset.id, + context_type: "home", + category: "impression", + } + } + window.fetch('/display_ad_events', { + method: 'POST', + headers: { + 'X-CSRF-Token': token, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(dataBody), + credentials: 'same-origin', + }) +} + +function trackAdClick(token, adBox) { + if (!adClicked) { + var dataBody = { + display_ad_event: { + display_ad_id: adBox.dataset.id, + context_type: "home", + category: "click", + } + } + window.fetch('/display_ad_events', { + method: 'POST', + headers: { + 'X-CSRF-Token': token, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(dataBody), + credentials: 'same-origin', + }) + } + adClicked = true; } \ No newline at end of file diff --git a/app/controllers/display_ad_events_controller.rb b/app/controllers/display_ad_events_controller.rb new file mode 100644 index 000000000..b4ca580a5 --- /dev/null +++ b/app/controllers/display_ad_events_controller.rb @@ -0,0 +1,29 @@ +class DisplayAdEventsController < ApplicationController + # No policy needed. All views are for all users + def create + # Only tracking for logged in users at the moment + display_ad_event_create_params = display_ad_event_params.merge(user_id: current_user.id) + @display_ad_event = DisplayAdEvent.create(display_ad_event_create_params) + + update_display_ads_data + + head :ok + end + + private + + def update_display_ads_data + return if Rails.env.production? && rand(20) != 1 # We need to do this operation only once in a while. + @display_ad = DisplayAd.find(display_ad_event_params[:display_ad_id]) + num_impressions = @display_ad.display_ad_events.where(category: "impression").size + num_clicks = @display_ad.display_ad_events.where(category: "click").size + rate = num_clicks.to_f / num_impressions.to_f + + @display_ad. + update_columns(success_rate: rate, clicks_count: num_clicks, impressions_count: num_impressions) + end + + def display_ad_event_params + params.require(:display_ad_event).permit(%i[context_type category display_ad_id]) + end +end \ No newline at end of file diff --git a/app/models/display_ad.rb b/app/models/display_ad.rb index dd815b54d..d3d71674c 100644 --- a/app/models/display_ad.rb +++ b/app/models/display_ad.rb @@ -1,11 +1,24 @@ class DisplayAd < ApplicationRecord belongs_to :organization + has_many :display_ad_events + validates :organization_id, presence: true validates :placement_area, presence: true, inclusion: { in: %w[sidebar_left sidebar_right] } validates :body_markdown, presence: true before_save :process_markdown + scope :approved_and_published, -> { where(approved: true, published: true) } + + + def self.for_display(area) + if rand(8) == 1 + approved_and_published.where(placement_area: area).order("success_rate DESC").sample + else + approved_and_published.where(placement_area: area).order("success_rate DESC").limit(rand(1..15)).sample + end + end + private def process_markdown diff --git a/app/models/display_ad_event.rb b/app/models/display_ad_event.rb new file mode 100644 index 000000000..9029f0720 --- /dev/null +++ b/app/models/display_ad_event.rb @@ -0,0 +1,8 @@ +class DisplayAdEvent < ApplicationRecord + + belongs_to :display_ad + belongs_to :user + + validates :category, inclusion: {in: %w[impression click]} + validates :context_type, inclusion: {in: %w[home]} +end diff --git a/app/models/user.rb b/app/models/user.rb index f0d9e96b9..77687ef8a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -45,6 +45,7 @@ class User < ApplicationRecord has_many :poll_votes has_many :poll_skips has_many :backup_data, foreign_key: "instance_user_id", inverse_of: :instance_user, class_name: "BackupData" + has_many :display_ad_events has_many :access_grants, class_name: "Doorkeeper::AccessGrant", foreign_key: :resource_owner_id, inverse_of: :resource_owner, dependent: :delete_all has_many :access_tokens, class_name: "Doorkeeper::AccessToken", foreign_key: :resource_owner_id, inverse_of: :resource_owner, dependent: :delete_all diff --git a/app/views/articles/_sidebar.html.erb b/app/views/articles/_sidebar.html.erb index 73feecc56..73333e667 100644 --- a/app/views/articles/_sidebar.html.erb +++ b/app/views/articles/_sidebar.html.erb @@ -51,9 +51,9 @@ <% end %> <% cache("display-area-left-#{rand(5)}", expires_in: 5.minutes) do %> - <% @left_sidebar_ad = DisplayAd.where(approved: true, published: true, placement_area: "sidebar_left").order(Arel.sql("RANDOM()")).first %> + <% @left_sidebar_ad = DisplayAd.for_display("sidebar_left") %> <% if @left_sidebar_ad %> -
+
<%= @left_sidebar_ad.processed_html.html_safe %>
diff --git a/app/views/articles/_sidebar_additional.html.erb b/app/views/articles/_sidebar_additional.html.erb index 59f779363..1c38a6975 100644 --- a/app/views/articles/_sidebar_additional.html.erb +++ b/app/views/articles/_sidebar_additional.html.erb @@ -19,7 +19,7 @@
<% end %> <% cache("main-article-right-sidebar-discussions-#{params[:timeframe]}", expires_in: (params[:timeframe].blank? ? 120 : 360).seconds) do %> - <% @sidebar_ad = DisplayAd.where(approved: true, published: true, placement_area: "sidebar_right").order(Arel.sql("RANDOM()")).first %> + <% @sidebar_ad = DisplayAd.for_display("sidebar_right") %> <% if @sidebar_ad %>
diff --git a/config/routes.rb b/config/routes.rb index aae544b6d..7f192005c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -160,6 +160,7 @@ Rails.application.routes.draw do resources :poll_skips, only: [:create] resources :profile_pins, only: %i[create update] resources :partnerships, only: %i[index create show], param: :option + resources :display_ad_events, only: [:create] get "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id" get "/listings/dashboard" => "classified_listings#dashboard" diff --git a/db/migrate/20190713213412_create_display_ad_events.rb b/db/migrate/20190713213412_create_display_ad_events.rb new file mode 100644 index 000000000..ede4839d7 --- /dev/null +++ b/db/migrate/20190713213412_create_display_ad_events.rb @@ -0,0 +1,16 @@ +class CreateDisplayAdEvents < ActiveRecord::Migration[5.2] + def change + create_table :display_ad_events do |t| + t.integer :display_ad_id + t.integer :user_id + t.string :category + t.string :context_type + t.bigint :context_id + t.integer :counts_for, default: 1 + + t.timestamps + end + add_index("display_ad_events", "display_ad_id") + add_index("display_ad_events", "user_id") + end +end diff --git a/db/migrate/20190713225409_add_success_rate_to_display_ads.rb b/db/migrate/20190713225409_add_success_rate_to_display_ads.rb new file mode 100644 index 000000000..1be92104a --- /dev/null +++ b/db/migrate/20190713225409_add_success_rate_to_display_ads.rb @@ -0,0 +1,5 @@ +class AddSuccessRateToDisplayAds < ActiveRecord::Migration[5.2] + def change + add_column :display_ads, :success_rate, :float, default: 0.0 + end +end diff --git a/db/schema.rb b/db/schema.rb index 492fdfca0..44cf75984 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -338,6 +338,19 @@ ActiveRecord::Schema.define(version: 2019_07_23_094834) do t.index ["priority", "run_at"], name: "delayed_jobs_priority" end + create_table "display_ad_events", force: :cascade do |t| + t.string "category" + t.bigint "context_id" + t.string "context_type" + t.integer "counts_for", default: 1 + t.datetime "created_at", null: false + t.integer "display_ad_id" + t.datetime "updated_at", null: false + t.integer "user_id" + t.index ["display_ad_id"], name: "index_display_ad_events_on_display_ad_id" + t.index ["user_id"], name: "index_display_ad_events_on_user_id" + end + create_table "display_ads", force: :cascade do |t| t.boolean "approved", default: false t.text "body_markdown" @@ -350,6 +363,7 @@ ActiveRecord::Schema.define(version: 2019_07_23_094834) do t.string "placement_area" t.text "processed_html" t.boolean "published", default: false + t.float "success_rate", default: 0.0 t.datetime "updated_at", null: false end diff --git a/spec/factories/display_ad_events.rb b/spec/factories/display_ad_events.rb new file mode 100644 index 000000000..a4c9a9e38 --- /dev/null +++ b/spec/factories/display_ad_events.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :display_ad_event do + category { "impression" } + context_type { "home" } + end +end diff --git a/spec/models/display_ad_event_spec.rb b/spec/models/display_ad_event_spec.rb new file mode 100644 index 000000000..f49c2826f --- /dev/null +++ b/spec/models/display_ad_event_spec.rb @@ -0,0 +1,21 @@ +require "rails_helper" + +RSpec.describe DisplayAdEvent, type: :model do + let(:user) { create(:user) } + let(:organization) { create(:organization) } + let(:display_ad) { create(:display_ad, organization_id: organization.id) } + + it "creates a click event" do + event = build(:display_ad_event, category: "click", user_id: user.id, display_ad_id: display_ad.id) + expect(event).to be_valid + end + it "creates an impression event" do + event = build(:display_ad_event, category: "impression", user_id: user.id, display_ad_id: display_ad.id) + expect(event).to be_valid + end + + it "does not create an invalid event" do + event = build(:display_ad_event, category: "wazoo", user_id: user.id, display_ad_id: display_ad.id) + expect(event).not_to be_valid + end +end diff --git a/spec/models/display_ad_spec.rb b/spec/models/display_ad_spec.rb index 9f4d0091a..44ff9fbbb 100644 --- a/spec/models/display_ad_spec.rb +++ b/spec/models/display_ad_spec.rb @@ -23,4 +23,13 @@ RSpec.describe DisplayAd, type: :model do display_ad.placement_area = "sidebar_left" expect(display_ad).to be_valid end + + it "displays published and approved posts" do + create(:display_ad, organization_id: organization.id, published: true, approved: true) + create(:display_ad, organization_id: organization.id, published: true, approved: true) + create(:display_ad, organization_id: organization.id, published: false, approved: true) + create(:display_ad, organization_id: organization.id, published: true, approved: false) + expect(DisplayAd.for_display(DisplayAd.last.placement_area).published).to eq(true) + expect(DisplayAd.for_display(DisplayAd.last.placement_area).approved).to eq(true) + end end diff --git a/spec/requests/display_ad_events_spec.rb b/spec/requests/display_ad_events_spec.rb new file mode 100644 index 000000000..aa8b944b0 --- /dev/null +++ b/spec/requests/display_ad_events_spec.rb @@ -0,0 +1,65 @@ +require "rails_helper" + +RSpec.describe "DisplayAdEvents", type: :request do + let(:user) { create(:user, :trusted) } + let(:organization) { create(:organization) } + let(:display_ad) { create(:display_ad, organization_id: organization.id) } + + describe "POST /display_ad_events" do + context "when user signed in" do + before do + sign_in user + end + + it "creates a display ad click event" do + post "/display_ad_events", params: { + display_ad_event: { + display_ad_id: display_ad.id, + context_type: "home", + category: "click", + } + } + expect(display_ad.reload.clicks_count).to eq(1) + end + it "creates a display ad impression event" do + post "/display_ad_events", params: { + display_ad_event: { + display_ad_id: display_ad.id, + context_type: "home", + category: "impression", + } + } + expect(display_ad.reload.impressions_count).to eq(1) + end + it "creates a display ad success rate" do + 4.times do + post "/display_ad_events", params: { + display_ad_event: { + display_ad_id: display_ad.id, + context_type: "home", + category: "impression", + } + } + end + post "/display_ad_events", params: { + display_ad_event: { + display_ad_id: display_ad.id, + context_type: "home", + category: "click", + } + } + expect(display_ad.reload.success_rate).to eq(0.25) + end + it "assigns event to current user" do + post "/display_ad_events", params: { + display_ad_event: { + display_ad_id: display_ad.id, + context_type: "home", + category: "impression", + } + } + expect(DisplayAdEvent.last.user_id).to eq(user.id) + end + end + end +end \ No newline at end of file