Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/api/v1/billboards_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def permitted_params
:audience_segment_type, :audience_segment_id, :priority, :special_behavior,
:custom_display_label, :template, :render_mode, :preferred_article_ids,
:exclude_role_names, :target_role_names, :include_subforem_ids, :prefer_paired_with_billboard_id,
:expires_at,
:expires_at, :exclude_survey_completions, :exclude_survey_ids,
# Permitting twice allows both comma-separated string and array values
:target_geolocations, target_geolocations: []
end
Expand Down
28 changes: 26 additions & 2 deletions app/controllers/poll_skips_controller.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,35 @@
class PollSkipsController < ApplicationController
before_action :authenticate_user!, only: %i[create]

POLL_SKIPS_PERMITTED_PARAMS = %i[poll_id].freeze
POLL_SKIPS_PERMITTED_PARAMS = %i[poll_id session_start].freeze

def create
poll = Poll.find(poll_skips_params[:poll_id])
poll.poll_skips.create_or_find_by(user_id: current_user.id)
session_start = poll_skips_params[:session_start]&.to_i || 0

# Check if this poll belongs to a survey and if resubmission is allowed
if poll.survey.present? && !poll.survey.can_user_submit?(current_user)
render json: { error: "Survey does not allow resubmission" }, status: :forbidden
return
end

# For survey polls, create skip with session_start
# For regular polls, use the old behavior
if poll.survey.present?
# Survey poll - create new skip with session
poll_skip = PollSkip.new(
user_id: current_user.id,
poll_id: poll.id,
session_start: session_start,
)
poll_skip.save!
else
# Regular poll - use old behavior
poll.poll_skips.create_or_find_by(user_id: current_user.id)
end

# Check if this skip completes a survey
SurveyCompletionService.check_and_mark_completion(user: current_user, poll: poll)

render json: {
voting_data: poll.voting_data,
Expand Down
3 changes: 3 additions & 0 deletions app/controllers/poll_text_responses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ def create
)

if @text_response.save
# Check if this response completes a survey
SurveyCompletionService.check_and_mark_completion(user: current_user, poll: @poll)

render json: { success: true, message: "Text response submitted successfully" }
else
render json: { success: false, errors: @text_response.errors.full_messages }, status: :unprocessable_entity
Expand Down
3 changes: 3 additions & 0 deletions app/controllers/poll_votes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ def create
poll_vote.save!
end

# Check if this vote completes a survey
SurveyCompletionService.check_and_mark_completion(user: current_user, poll: poll)

render json: { voting_data: poll.voting_data,
poll_id: poll.id,
user_vote_poll_option_id: poll_vote_params[:poll_option_id].to_i,
Expand Down
20 changes: 19 additions & 1 deletion app/models/billboard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ class Billboard < ApplicationRecord
after_save :update_links_with_bb_param
after_save :update_event_counts_when_taking_down, if: -> { being_taken_down? }

scope :approved_and_published, -> { where(approved: true, published: true).where("expires_at IS NULL OR expires_at > ?", Time.current) }
scope :approved_and_published, lambda {
where(approved: true, published: true).where("expires_at IS NULL OR expires_at > ?", Time.current)
}

scope :search_ads, lambda { |term|
where "name ILIKE :search OR processed_html ILIKE :search OR placement_area ILIKE :search",
Expand Down Expand Up @@ -296,6 +298,7 @@ def as_json(options = {})
"audience_segment_type" => audience_segment_type,
"tag_list" => cached_tag_list,
"exclude_article_ids" => exclude_article_ids.join(","),
"exclude_survey_ids" => exclude_survey_ids.join(","),
"target_geolocations" => target_geolocations.map(&:to_iso3166)
}
super(options.merge(except: %i[tags tag_list target_geolocations])).merge(overrides)
Expand Down Expand Up @@ -332,6 +335,12 @@ def include_subforem_ids=(input)
write_attribute :include_subforem_ids, (adjusted_input || [])
end

def exclude_survey_ids=(input)
adjusted_input = input.is_a?(String) ? input.split(",") : input
adjusted_input = adjusted_input&.filter_map { |value| value.presence&.to_i }
write_attribute :exclude_survey_ids, (adjusted_input || [])
end

def style_string
return "" if color.blank?

Expand Down Expand Up @@ -386,6 +395,15 @@ def check_and_handle_expiration
update_column(:approved, false)
end

# Check if a user should be excluded from seeing this billboard based on survey completion
def exclude_user_due_to_survey_completion?(user)
return false unless exclude_survey_completions?
return false if user.blank?
return false if exclude_survey_ids.blank?

SurveyCompletion.user_completed_any?(user: user, survey_ids: exclude_survey_ids)
end

private

def update_event_counts_when_taking_down
Expand Down
16 changes: 16 additions & 0 deletions app/models/survey.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
class Survey < ApplicationRecord
has_many :polls, -> { order(:position) }, dependent: :nullify
has_many :poll_votes, through: :polls
has_many :survey_completions, dependent: :destroy

# Check if a user has completed all polls in this survey in their latest session
def completed_by_user?(user)
Expand Down Expand Up @@ -48,4 +49,19 @@ def get_latest_session(user)
def generate_new_session(user)
get_latest_session(user) + 1
end

# Mark a survey as completed for a user and create a SurveyCompletion record
def mark_completed_by_user!(user)
return false unless user
return false unless completed_by_user?(user)

SurveyCompletion.mark_completed!(user: user, survey: self)
end

# Check if a user has a completion record for this survey
def completion_recorded_for_user?(user)
return false unless user

survey_completions.exists?(user: user)
end
end
31 changes: 31 additions & 0 deletions app/models/survey_completion.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
class SurveyCompletion < ApplicationRecord
belongs_to :user
belongs_to :survey

validates :user_id, uniqueness: { scope: :survey_id }
validates :completed_at, presence: true

scope :for_user, ->(user) { where(user: user) }
scope :for_surveys, ->(survey_ids) { where(survey_id: survey_ids) }

# Mark a survey as completed for a user
def self.mark_completed!(user:, survey:)
find_or_create_by(user: user, survey: survey) do |completion|
completion.completed_at = Time.current
end
end

# Check if a user has completed any of the given surveys
def self.user_completed_any?(user:, survey_ids:)
return false if user.blank? || survey_ids.blank?

where(user: user, survey_id: survey_ids).exists?
end

# Get all survey IDs that a user has completed
def self.completed_survey_ids_for_user(user)
return [] if user.blank?

where(user: user).pluck(:survey_id)
end
end
1 change: 1 addition & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ class User < ApplicationRecord
has_many :poll_skips, dependent: :delete_all
has_many :poll_votes, dependent: :delete_all
has_many :poll_text_responses, dependent: :delete_all
has_many :survey_completions, dependent: :destroy
has_many :profile_pins, as: :profile, inverse_of: :profile, dependent: :delete_all
has_many :segmented_users, dependent: :destroy
has_many :audience_segments, through: :segmented_users
Expand Down
33 changes: 31 additions & 2 deletions app/queries/billboards/filtered_ads_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def initialize(area:, user_signed_in:, organization_id: nil, article_tags: [], p
def call
@filtered_billboards = approved_and_published_ads
@filtered_billboards = placement_area_ads
@filtered_billboards = included_subforem_ads #if @subforem_id.present?
@filtered_billboards = included_subforem_ads # if @subforem_id.present?
@filtered_billboards = browser_context_ads if @user_agent.present?
@filtered_billboards = page_ads if @page_id.present?
@filtered_billboards = cookies_allowed_ads unless @cookies_allowed
Expand Down Expand Up @@ -79,6 +79,11 @@ def call
# filters applied up to this point, thus near the end is best)
@filtered_billboards = type_of_ads

# Apply survey completion filtering if user is signed in
if @user_signed_in && ApplicationConfig["SKIP_SURVEY_COMPLETION_FILTERING"] != "yes"
@filtered_billboards = survey_completion_filtered_ads
end

@filtered_billboards = @filtered_billboards.order(success_rate: :desc)
end

Expand Down Expand Up @@ -123,7 +128,7 @@ def unexcluded_article_ads

def included_subforem_ads
@filtered_billboards.where("cardinality(include_subforem_ids) = 0 OR :subforem_id = ANY(include_subforem_ids)",
subforem_id: @subforem_id)
subforem_id: @subforem_id)
end

def authenticated_ads(display_auth_audience)
Expand Down Expand Up @@ -180,5 +185,29 @@ def type_of_ads

@filtered_billboards.where(type_of: Billboard.type_ofs.slice(*types_matching).values)
end

def survey_completion_filtered_ads
return @filtered_billboards unless @user_id.present?

# Get billboards that either don't exclude survey completions or
# exclude survey completions but the user hasn't completed any of the specified surveys
billboards_without_survey_exclusion = @filtered_billboards.where(exclude_survey_completions: false)

# For billboards that do exclude survey completions, we need to check if the user
# has completed any of the surveys specified in exclude_survey_ids
billboards_with_survey_exclusion = @filtered_billboards.where(exclude_survey_completions: true)

if billboards_with_survey_exclusion.any?
# Get survey IDs that the user has completed
completed_survey_ids = SurveyCompletion.completed_survey_ids_for_user(@user_id)

# Filter out billboards where the user has completed any of the excluded surveys
billboards_with_survey_exclusion = billboards_with_survey_exclusion.where(
"NOT (exclude_survey_ids && ARRAY[?]::integer[])", completed_survey_ids
)
end

billboards_without_survey_exclusion.or(billboards_with_survey_exclusion)
end
end
end
14 changes: 14 additions & 0 deletions app/services/survey_completion_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class SurveyCompletionService
def self.check_and_mark_completion(user:, poll:)
return unless poll.survey.present?
return unless user.present?

survey = poll.survey

# Check if the survey is now completed by the user
return unless survey.completed_by_user?(user)

# Mark the survey as completed if it hasn't been marked already
survey.mark_completed_by_user!(user) unless survey.completion_recorded_for_user?(user)
end
end
20 changes: 20 additions & 0 deletions db/migrate/20250904134411_create_survey_completions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class CreateSurveyCompletions < ActiveRecord::Migration[7.0]
def change
create_table :survey_completions do |t|
t.references :user, null: false, foreign_key: true
t.references :survey, null: false, foreign_key: true
t.datetime :completed_at, null: false

t.timestamps
end

# Add unique index to prevent duplicate completions
add_index :survey_completions, [:user_id, :survey_id], unique: true, name: 'idx_survey_completions_user_survey'

# Add index for efficient lookups by user
add_index :survey_completions, :user_id, name: 'idx_survey_completions_user'

# Add index for efficient lookups by survey
add_index :survey_completions, :survey_id, name: 'idx_survey_completions_survey'
end
end
12 changes: 12 additions & 0 deletions db/migrate/20250904134417_add_survey_exclusion_to_billboards.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class AddSurveyExclusionToBillboards < ActiveRecord::Migration[7.0]
disable_ddl_transaction!

def change
add_column :display_ads, :exclude_survey_completions, :boolean, default: false, null: false
add_column :display_ads, :exclude_survey_ids, :integer, default: [], array: true, null: false

# Add index for efficient filtering by survey exclusion
add_index :display_ads, :exclude_survey_completions, name: 'idx_display_ads_survey_completions', algorithm: :concurrently
add_index :display_ads, :exclude_survey_ids, using: :gin, name: 'idx_display_ads_survey_ids', algorithm: :concurrently
end
end
21 changes: 20 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.0].define(version: 2025_09_02_150028) do
ActiveRecord::Schema[7.0].define(version: 2025_09_04_134417) do
# These are extensions that must be enabled in order to support this database
enable_extension "citext"
enable_extension "ltree"
Expand Down Expand Up @@ -520,6 +520,8 @@
t.integer "display_to", default: 0, null: false
t.integer "exclude_article_ids", default: [], array: true
t.string "exclude_role_names", default: [], array: true
t.boolean "exclude_survey_completions", default: false, null: false
t.integer "exclude_survey_ids", default: [], null: false, array: true
t.datetime "expires_at"
t.integer "impressions_count", default: 0
t.integer "include_subforem_ids", default: [], array: true
Expand All @@ -545,6 +547,8 @@
t.index ["cached_tag_list"], name: "index_display_ads_on_cached_tag_list", opclass: :gin_trgm_ops, using: :gin
t.index ["exclude_article_ids"], name: "index_display_ads_on_exclude_article_ids", using: :gin
t.index ["exclude_role_names"], name: "index_display_ads_on_exclude_role_names", using: :gin
t.index ["exclude_survey_completions"], name: "idx_display_ads_survey_completions"
t.index ["exclude_survey_ids"], name: "idx_display_ads_survey_ids", using: :gin
t.index ["include_subforem_ids"], name: "index_display_ads_on_include_subforem_ids", using: :gin
t.index ["page_id"], name: "index_display_ads_on_page_id"
t.index ["placement_area"], name: "index_display_ads_on_placement_area"
Expand Down Expand Up @@ -1293,6 +1297,19 @@
t.index ["score"], name: "index_subforems_on_score"
end

create_table "survey_completions", force: :cascade do |t|
t.datetime "completed_at", null: false
t.datetime "created_at", null: false
t.bigint "survey_id", null: false
t.datetime "updated_at", null: false
t.bigint "user_id", null: false
t.index ["survey_id"], name: "idx_survey_completions_survey"
t.index ["survey_id"], name: "index_survey_completions_on_survey_id"
t.index ["user_id", "survey_id"], name: "idx_survey_completions_user_survey", unique: true
t.index ["user_id"], name: "idx_survey_completions_user"
t.index ["user_id"], name: "index_survey_completions_on_user_id"
end

create_table "surveys", force: :cascade do |t|
t.boolean "active", default: true
t.boolean "allow_resubmission", default: false, null: false
Expand Down Expand Up @@ -1731,6 +1748,8 @@
add_foreign_key "response_templates", "users"
add_foreign_key "segmented_users", "audience_segments"
add_foreign_key "segmented_users", "users"
add_foreign_key "survey_completions", "surveys"
add_foreign_key "survey_completions", "users"
add_foreign_key "tag_adjustments", "articles", on_delete: :cascade
add_foreign_key "tag_adjustments", "tags", on_delete: :cascade
add_foreign_key "tag_adjustments", "users", on_delete: :cascade
Expand Down
7 changes: 7 additions & 0 deletions spec/factories/survey_completions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FactoryBot.define do
factory :survey_completion do
user
survey
completed_at { Time.current }
end
end
Loading
Loading